@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9
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/README.md +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct unit tests for cli/formatting.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests all pure formatting helpers: ANSI color, duration formatting,
|
|
5
|
+
* progress bars, padding, token formatting, and usage rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ansi,
|
|
12
|
+
c,
|
|
13
|
+
fmtTokens,
|
|
14
|
+
formatDuration,
|
|
15
|
+
formatResetTime,
|
|
16
|
+
formatTimeAgo,
|
|
17
|
+
pad,
|
|
18
|
+
QUOTA_BUCKETS,
|
|
19
|
+
renderBar,
|
|
20
|
+
renderUsageLines,
|
|
21
|
+
rpad,
|
|
22
|
+
setUseColor,
|
|
23
|
+
shortPath,
|
|
24
|
+
stripAnsi,
|
|
25
|
+
USAGE_INDENT,
|
|
26
|
+
USAGE_LABEL_WIDTH,
|
|
27
|
+
} from "./formatting.js";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Color helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
describe("ansi — ANSI escape wrapping", () => {
|
|
34
|
+
beforeEach(() => setUseColor(true));
|
|
35
|
+
afterEach(() => setUseColor(true));
|
|
36
|
+
|
|
37
|
+
it("wraps text with ANSI codes when color is enabled", () => {
|
|
38
|
+
expect(ansi("32", "hello")).toBe("\x1b[32mhello\x1b[0m");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns plain text when color is disabled", () => {
|
|
42
|
+
setUseColor(false);
|
|
43
|
+
expect(ansi("32", "hello")).toBe("hello");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("c — color shorthand object", () => {
|
|
48
|
+
beforeEach(() => setUseColor(true));
|
|
49
|
+
afterEach(() => setUseColor(true));
|
|
50
|
+
|
|
51
|
+
it("bold wraps with code 1", () => {
|
|
52
|
+
expect(c.bold("test")).toContain("\x1b[1m");
|
|
53
|
+
expect(c.bold("test")).toContain("test");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("dim wraps with code 2", () => {
|
|
57
|
+
expect(c.dim("test")).toContain("\x1b[2m");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("green wraps with code 32", () => {
|
|
61
|
+
expect(c.green("ok")).toContain("\x1b[32m");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("yellow wraps with code 33", () => {
|
|
65
|
+
expect(c.yellow("warn")).toContain("\x1b[33m");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("cyan wraps with code 36", () => {
|
|
69
|
+
expect(c.cyan("info")).toContain("\x1b[36m");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("red wraps with code 31", () => {
|
|
73
|
+
expect(c.red("err")).toContain("\x1b[31m");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("gray wraps with code 90", () => {
|
|
77
|
+
expect(c.gray("muted")).toContain("\x1b[90m");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("all functions return plain text when color disabled", () => {
|
|
81
|
+
setUseColor(false);
|
|
82
|
+
expect(c.bold("a")).toBe("a");
|
|
83
|
+
expect(c.dim("b")).toBe("b");
|
|
84
|
+
expect(c.green("c")).toBe("c");
|
|
85
|
+
expect(c.red("d")).toBe("d");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// stripAnsi
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
describe("stripAnsi — ANSI removal", () => {
|
|
94
|
+
it("returns plain text unchanged", () => {
|
|
95
|
+
expect(stripAnsi("hello world")).toBe("hello world");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("strips color codes", () => {
|
|
99
|
+
expect(stripAnsi("\x1b[32mgreen\x1b[0m")).toBe("green");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("strips bold + dim", () => {
|
|
103
|
+
expect(stripAnsi("\x1b[1mbold\x1b[0m \x1b[2mdim\x1b[0m")).toBe("bold dim");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("strips nested codes", () => {
|
|
107
|
+
expect(stripAnsi("\x1b[1m\x1b[31mred bold\x1b[0m\x1b[0m")).toBe("red bold");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles empty string", () => {
|
|
111
|
+
expect(stripAnsi("")).toBe("");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// formatDuration
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
describe("formatDuration — milliseconds to human string", () => {
|
|
120
|
+
it("returns 'now' for zero or negative", () => {
|
|
121
|
+
expect(formatDuration(0)).toBe("now");
|
|
122
|
+
expect(formatDuration(-100)).toBe("now");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("formats seconds", () => {
|
|
126
|
+
expect(formatDuration(5000)).toBe("5s");
|
|
127
|
+
expect(formatDuration(59000)).toBe("59s");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("formats minutes", () => {
|
|
131
|
+
expect(formatDuration(60_000)).toBe("1m");
|
|
132
|
+
expect(formatDuration(90_000)).toBe("1m 30s");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("formats hours", () => {
|
|
136
|
+
expect(formatDuration(3_600_000)).toBe("1h");
|
|
137
|
+
expect(formatDuration(5_400_000)).toBe("1h 30m");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("formats days", () => {
|
|
141
|
+
expect(formatDuration(86_400_000)).toBe("1d");
|
|
142
|
+
expect(formatDuration(90_000_000)).toBe("1d 1h");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("drops trailing zeroes at each level", () => {
|
|
146
|
+
expect(formatDuration(120_000)).toBe("2m");
|
|
147
|
+
expect(formatDuration(7_200_000)).toBe("2h");
|
|
148
|
+
expect(formatDuration(172_800_000)).toBe("2d");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("handles sub-second (rounds to 0s)", () => {
|
|
152
|
+
expect(formatDuration(500)).toBe("0s");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// formatTimeAgo
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
describe("formatTimeAgo — timestamp to relative string", () => {
|
|
161
|
+
it("returns 'never' for null/undefined/zero", () => {
|
|
162
|
+
expect(formatTimeAgo(null)).toBe("never");
|
|
163
|
+
expect(formatTimeAgo(undefined)).toBe("never");
|
|
164
|
+
expect(formatTimeAgo(0)).toBe("never");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns 'just now' for future timestamps", () => {
|
|
168
|
+
expect(formatTimeAgo(Date.now() + 60_000)).toBe("just now");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns duration + 'ago' for past timestamps", () => {
|
|
172
|
+
const fiveMinAgo = Date.now() - 300_000;
|
|
173
|
+
expect(formatTimeAgo(fiveMinAgo)).toBe("5m ago");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// formatResetTime
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe("formatResetTime — ISO date to remaining duration", () => {
|
|
182
|
+
it("returns 'now' for past dates", () => {
|
|
183
|
+
expect(formatResetTime("2020-01-01T00:00:00Z")).toBe("now");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns duration for future dates", () => {
|
|
187
|
+
const futureMs = Date.now() + 3_600_000;
|
|
188
|
+
const result = formatResetTime(new Date(futureMs).toISOString());
|
|
189
|
+
// Should contain 'h' or 'm' — at least not "now"
|
|
190
|
+
expect(result).not.toBe("now");
|
|
191
|
+
expect(result).toMatch(/\d+[hms]/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// shortPath
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe("shortPath — home directory abbreviation", () => {
|
|
200
|
+
const originalHome = process.env.HOME;
|
|
201
|
+
afterEach(() => {
|
|
202
|
+
process.env.HOME = originalHome;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("replaces HOME prefix with ~", () => {
|
|
206
|
+
process.env.HOME = "/Users/testuser";
|
|
207
|
+
expect(shortPath("/Users/testuser/.config/opencode/file.json")).toBe("~/.config/opencode/file.json");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns path unchanged when HOME is not a prefix", () => {
|
|
211
|
+
process.env.HOME = "/Users/testuser";
|
|
212
|
+
expect(shortPath("/etc/config")).toBe("/etc/config");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("handles missing HOME gracefully", () => {
|
|
216
|
+
process.env.HOME = "";
|
|
217
|
+
expect(shortPath("/some/path")).toBe("/some/path");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// pad / rpad
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
describe("pad — left-pad to visible width", () => {
|
|
226
|
+
beforeEach(() => setUseColor(false));
|
|
227
|
+
afterEach(() => setUseColor(true));
|
|
228
|
+
|
|
229
|
+
it("pads short strings with trailing spaces", () => {
|
|
230
|
+
expect(pad("hi", 5)).toBe("hi ");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("returns string unchanged when already >= width", () => {
|
|
234
|
+
expect(pad("hello", 3)).toBe("hello");
|
|
235
|
+
expect(pad("exact", 5)).toBe("exact");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("accounts for ANSI codes in width calculation", () => {
|
|
239
|
+
setUseColor(true);
|
|
240
|
+
const colored = c.green("ok");
|
|
241
|
+
const padded = pad(colored, 10);
|
|
242
|
+
// Visible text "ok" is 2 chars, so 8 spaces should be added
|
|
243
|
+
expect(stripAnsi(padded)).toBe("ok ");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("rpad — right-pad (left-aligned spaces)", () => {
|
|
248
|
+
beforeEach(() => setUseColor(false));
|
|
249
|
+
afterEach(() => setUseColor(true));
|
|
250
|
+
|
|
251
|
+
it("prepends spaces for short strings", () => {
|
|
252
|
+
expect(rpad("hi", 5)).toBe(" hi");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns string unchanged when already >= width", () => {
|
|
256
|
+
expect(rpad("hello", 3)).toBe("hello");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// renderBar
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
describe("renderBar — progress bar rendering", () => {
|
|
265
|
+
beforeEach(() => setUseColor(false));
|
|
266
|
+
afterEach(() => setUseColor(true));
|
|
267
|
+
|
|
268
|
+
it("renders full bar at 100%", () => {
|
|
269
|
+
const bar = renderBar(100, 10);
|
|
270
|
+
expect(stripAnsi(bar)).toBe("██████████");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("renders empty bar at 0%", () => {
|
|
274
|
+
const bar = renderBar(0, 10);
|
|
275
|
+
expect(stripAnsi(bar)).toBe("░░░░░░░░░░");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("renders partial bar at 50%", () => {
|
|
279
|
+
const bar = renderBar(50, 10);
|
|
280
|
+
const text = stripAnsi(bar);
|
|
281
|
+
expect(text).toHaveLength(10);
|
|
282
|
+
expect(text).toContain("█");
|
|
283
|
+
expect(text).toContain("░");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("clamps values below 0", () => {
|
|
287
|
+
const bar = renderBar(-10, 10);
|
|
288
|
+
expect(stripAnsi(bar)).toBe("░░░░░░░░░░");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("clamps values above 100", () => {
|
|
292
|
+
const bar = renderBar(150, 10);
|
|
293
|
+
expect(stripAnsi(bar)).toBe("██████████");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("uses red color for >= 90%", () => {
|
|
297
|
+
setUseColor(true);
|
|
298
|
+
const bar = renderBar(95, 10);
|
|
299
|
+
expect(bar).toContain("\x1b[31m"); // red
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("uses yellow color for >= 70% and < 90%", () => {
|
|
303
|
+
setUseColor(true);
|
|
304
|
+
const bar = renderBar(75, 10);
|
|
305
|
+
expect(bar).toContain("\x1b[33m"); // yellow
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("uses green color for < 70%", () => {
|
|
309
|
+
setUseColor(true);
|
|
310
|
+
const bar = renderBar(30, 10);
|
|
311
|
+
expect(bar).toContain("\x1b[32m"); // green
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("respects custom width", () => {
|
|
315
|
+
const bar = renderBar(50, 20);
|
|
316
|
+
expect(stripAnsi(bar)).toHaveLength(20);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// fmtTokens
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
describe("fmtTokens — token count formatting", () => {
|
|
325
|
+
it("formats millions", () => {
|
|
326
|
+
expect(fmtTokens(1_000_000)).toBe("1.0M");
|
|
327
|
+
expect(fmtTokens(2_500_000)).toBe("2.5M");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("formats thousands", () => {
|
|
331
|
+
expect(fmtTokens(1_000)).toBe("1.0K");
|
|
332
|
+
expect(fmtTokens(42_300)).toBe("42.3K");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("formats small numbers as-is", () => {
|
|
336
|
+
expect(fmtTokens(0)).toBe("0");
|
|
337
|
+
expect(fmtTokens(999)).toBe("999");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// renderUsageLines
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
describe("renderUsageLines — usage quota rendering", () => {
|
|
346
|
+
beforeEach(() => setUseColor(false));
|
|
347
|
+
afterEach(() => setUseColor(true));
|
|
348
|
+
|
|
349
|
+
it("returns empty array for empty usage object", () => {
|
|
350
|
+
expect(renderUsageLines({})).toEqual([]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("skips buckets without utilization", () => {
|
|
354
|
+
expect(renderUsageLines({ five_hour: {} })).toEqual([]);
|
|
355
|
+
expect(renderUsageLines({ five_hour: { utilization: null } })).toEqual([]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("renders a single bucket line with bar and percentage", () => {
|
|
359
|
+
const lines = renderUsageLines({ five_hour: { utilization: 45 } });
|
|
360
|
+
expect(lines).toHaveLength(1);
|
|
361
|
+
const plain = stripAnsi(lines[0]);
|
|
362
|
+
expect(plain).toContain("5h");
|
|
363
|
+
expect(plain).toContain("45%");
|
|
364
|
+
expect(plain).toContain("█");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("renders multiple buckets in order", () => {
|
|
368
|
+
const lines = renderUsageLines({
|
|
369
|
+
five_hour: { utilization: 10 },
|
|
370
|
+
seven_day: { utilization: 70 },
|
|
371
|
+
});
|
|
372
|
+
expect(lines).toHaveLength(2);
|
|
373
|
+
expect(stripAnsi(lines[0])).toContain("5h");
|
|
374
|
+
expect(stripAnsi(lines[1])).toContain("7d");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("includes reset time when resets_at is present", () => {
|
|
378
|
+
const future = new Date(Date.now() + 3_600_000).toISOString();
|
|
379
|
+
const lines = renderUsageLines({
|
|
380
|
+
five_hour: { utilization: 50, resets_at: future },
|
|
381
|
+
});
|
|
382
|
+
const plain = stripAnsi(lines[0]);
|
|
383
|
+
expect(plain).toContain("resets in");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("skips unknown bucket keys", () => {
|
|
387
|
+
const lines = renderUsageLines({ unknown_bucket: { utilization: 50 } });
|
|
388
|
+
expect(lines).toEqual([]);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// QUOTA_BUCKETS / constants
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
describe("QUOTA_BUCKETS — bucket definitions", () => {
|
|
397
|
+
it("has expected number of buckets", () => {
|
|
398
|
+
expect(QUOTA_BUCKETS.length).toBeGreaterThanOrEqual(4);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("includes five_hour and seven_day", () => {
|
|
402
|
+
const keys = QUOTA_BUCKETS.map((b) => b.key);
|
|
403
|
+
expect(keys).toContain("five_hour");
|
|
404
|
+
expect(keys).toContain("seven_day");
|
|
405
|
+
});
|
|
406
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI formatting and rendering utilities.
|
|
3
|
+
*
|
|
4
|
+
* Pure formatting helpers for ANSI colors, durations, progress bars,
|
|
5
|
+
* and terminal output formatting. Zero dependencies, respects NO_COLOR / TTY.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Color helpers — zero dependencies, respects NO_COLOR / TTY
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
let USE_COLOR = !process.env.NO_COLOR && process.stdout.isTTY !== false;
|
|
13
|
+
|
|
14
|
+
/** Enable or disable color output globally. */
|
|
15
|
+
export function setUseColor(value: boolean) {
|
|
16
|
+
USE_COLOR = value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {string} code @param {string} text @returns {string} */
|
|
20
|
+
export function ansi(code: string, text: string) {
|
|
21
|
+
return USE_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const c = {
|
|
25
|
+
bold: (t: string) => ansi("1", t),
|
|
26
|
+
dim: (t: string) => ansi("2", t),
|
|
27
|
+
green: (t: string) => ansi("32", t),
|
|
28
|
+
yellow: (t: string) => ansi("33", t),
|
|
29
|
+
cyan: (t: string) => ansi("36", t),
|
|
30
|
+
red: (t: string) => ansi("31", t),
|
|
31
|
+
gray: (t: string) => ansi("90", t),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// ANSI escape code handling
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strip ANSI escape codes from a string to get its visible content.
|
|
40
|
+
* @param {string} str
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function stripAnsi(str: string): string {
|
|
44
|
+
// eslint-disable-next-line no-control-regex -- ANSI escape sequences start with \x1b which is a control char
|
|
45
|
+
return str.replace(new RegExp("\x1b\\[[0-9;]*m", "g"), "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Duration and time formatting
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format milliseconds as a human-readable duration.
|
|
54
|
+
* @param {number} ms
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function formatDuration(ms: number): string {
|
|
58
|
+
if (ms <= 0) return "now";
|
|
59
|
+
const seconds = Math.floor(ms / 1000);
|
|
60
|
+
if (seconds < 60) return `${seconds}s`;
|
|
61
|
+
const minutes = Math.floor(seconds / 60);
|
|
62
|
+
const remainSec = seconds % 60;
|
|
63
|
+
if (minutes < 60) return remainSec > 0 ? `${minutes}m ${remainSec}s` : `${minutes}m`;
|
|
64
|
+
const hours = Math.floor(minutes / 60);
|
|
65
|
+
const remainMin = minutes % 60;
|
|
66
|
+
if (hours < 24) return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
|
|
67
|
+
const days = Math.floor(hours / 24);
|
|
68
|
+
const remainHours = hours % 24;
|
|
69
|
+
return remainHours > 0 ? `${days}d ${remainHours}h` : `${days}d`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Format a timestamp as relative time ago.
|
|
74
|
+
* @param {number} timestamp
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function formatTimeAgo(timestamp: number | null | undefined): string {
|
|
78
|
+
if (!timestamp || timestamp === 0) return "never";
|
|
79
|
+
const ms = Date.now() - timestamp;
|
|
80
|
+
if (ms < 0) return "just now";
|
|
81
|
+
return `${formatDuration(ms)} ago`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format an ISO 8601 reset timestamp as a relative duration from now.
|
|
86
|
+
* @param {string} isoString
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function formatResetTime(isoString: string): string {
|
|
90
|
+
const resetMs = new Date(isoString).getTime();
|
|
91
|
+
const remaining = resetMs - Date.now();
|
|
92
|
+
if (remaining <= 0) return "now";
|
|
93
|
+
return formatDuration(remaining);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Path formatting
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Shorten a path by replacing home directory with ~.
|
|
102
|
+
* @param {string} p
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
export function shortPath(p: string): string {
|
|
106
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
107
|
+
if (home && p.startsWith(home)) return "~" + p.slice(home.length);
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Padding and alignment helpers
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Left-pad a string to a fixed visible width, accounting for ANSI escape codes.
|
|
117
|
+
* @param {string} str
|
|
118
|
+
* @param {number} width
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function pad(str: string, width: number): string {
|
|
122
|
+
const diff = width - stripAnsi(str).length;
|
|
123
|
+
return diff > 0 ? str + " ".repeat(diff) : str;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Right-align a string to a fixed visible width, accounting for ANSI escape codes.
|
|
128
|
+
* @param {string} str
|
|
129
|
+
* @param {number} width
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
export function rpad(str: string, width: number): string {
|
|
133
|
+
const diff = width - stripAnsi(str).length;
|
|
134
|
+
return diff > 0 ? " ".repeat(diff) + str : str;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Progress bar rendering
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Render a progress bar of a given width for a utilization percentage (0–100).
|
|
143
|
+
* @param {number} utilization - percentage (0 to 100)
|
|
144
|
+
* @param {number} [width=10] - bar character width
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function renderBar(utilization: number, width = 10): string {
|
|
148
|
+
const pct = Math.max(0, Math.min(100, utilization));
|
|
149
|
+
const filled = Math.round((pct / 100) * width);
|
|
150
|
+
const empty = width - filled;
|
|
151
|
+
|
|
152
|
+
let bar: string;
|
|
153
|
+
if (pct >= 90) {
|
|
154
|
+
bar = c.red("█".repeat(filled)) + c.dim("░".repeat(empty));
|
|
155
|
+
} else if (pct >= 70) {
|
|
156
|
+
bar = c.yellow("█".repeat(filled)) + c.dim("░".repeat(empty));
|
|
157
|
+
} else {
|
|
158
|
+
bar = c.green("█".repeat(filled)) + c.dim("░".repeat(empty));
|
|
159
|
+
}
|
|
160
|
+
return bar;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Usage quota rendering
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Known usage quota buckets and their display labels.
|
|
169
|
+
* Order determines display order.
|
|
170
|
+
*/
|
|
171
|
+
export const QUOTA_BUCKETS = [
|
|
172
|
+
{ key: "five_hour", label: "5h" },
|
|
173
|
+
{ key: "seven_day", label: "7d" },
|
|
174
|
+
{ key: "seven_day_sonnet", label: "Sonnet 7d" },
|
|
175
|
+
{ key: "seven_day_opus", label: "Opus 7d" },
|
|
176
|
+
{ key: "seven_day_oauth_apps", label: "OAuth Apps 7d" },
|
|
177
|
+
{ key: "seven_day_cowork", label: "Cowork 7d" },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
export const USAGE_INDENT = " ";
|
|
181
|
+
export const USAGE_LABEL_WIDTH = 13;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render usage quota lines for an account.
|
|
185
|
+
* Returns an array of pre-formatted strings (one per non-null bucket).
|
|
186
|
+
* @param {Record<string, any>} usage
|
|
187
|
+
* @returns {string[]}
|
|
188
|
+
*/
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- upstream Anthropic usage API response has unstable bucket shapes
|
|
190
|
+
export function renderUsageLines(usage: Record<string, any>): string[] {
|
|
191
|
+
const lines = [];
|
|
192
|
+
for (const { key, label } of QUOTA_BUCKETS) {
|
|
193
|
+
const bucket = usage[key];
|
|
194
|
+
if (!bucket || bucket.utilization == null) continue;
|
|
195
|
+
|
|
196
|
+
const pct = bucket.utilization;
|
|
197
|
+
const bar = renderBar(pct);
|
|
198
|
+
const pctStr = pad(String(Math.round(pct)) + "%", 4);
|
|
199
|
+
const reset = bucket.resets_at ? c.dim(`resets in ${formatResetTime(bucket.resets_at)}`) : "";
|
|
200
|
+
|
|
201
|
+
lines.push(`${USAGE_INDENT}${pad(label, USAGE_LABEL_WIDTH)} ${bar} ${pctStr}${reset ? ` ${reset}` : ""}`);
|
|
202
|
+
}
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Token formatting
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format a token count for display. Uses K/M suffixes for readability.
|
|
212
|
+
* @param {number} n
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function fmtTokens(n: number): string {
|
|
216
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
217
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
|
218
|
+
return String(n);
|
|
219
|
+
}
|