@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.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. 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
+ }