opencode-anthropic-fix 0.0.14

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/cli.test.mjs ADDED
@@ -0,0 +1,1797 @@
1
+ /**
2
+ * Tests for the CLI account management tool.
3
+ *
4
+ * We mock storage and config to control what the CLI sees,
5
+ * and capture console output to verify formatting.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Mocks
11
+ // ---------------------------------------------------------------------------
12
+
13
+ vi.mock("./lib/storage.mjs", async (importOriginal) => {
14
+ const original = await importOriginal();
15
+ return {
16
+ ...original,
17
+ loadAccounts: vi.fn().mockResolvedValue(null),
18
+ saveAccounts: vi.fn().mockResolvedValue(undefined),
19
+ clearAccounts: vi.fn().mockResolvedValue(undefined),
20
+ getStoragePath: vi.fn(() => "/home/user/.config/opencode/anthropic-accounts.json"),
21
+ };
22
+ });
23
+
24
+ vi.mock("./lib/config.mjs", async (importOriginal) => {
25
+ const original = await importOriginal();
26
+ return {
27
+ ...original,
28
+ loadConfig: vi.fn(() => ({ ...original.DEFAULT_CONFIG })),
29
+ getConfigPath: vi.fn(() => "/home/user/.config/opencode/anthropic-auth.json"),
30
+ getConfigDir: vi.fn(() => "/home/user/.config/opencode"),
31
+ };
32
+ });
33
+
34
+ vi.mock("./lib/oauth.mjs", () => ({
35
+ authorize: vi.fn(async () => ({ url: "https://auth.example/authorize", verifier: "pkce-verifier" })),
36
+ exchange: vi.fn(async () => ({
37
+ type: "success",
38
+ refresh: "refresh-new",
39
+ access: "access-new",
40
+ expires: Date.now() + 3600_000,
41
+ email: "new@example.com",
42
+ })),
43
+ revoke: vi.fn(async () => true),
44
+ }));
45
+
46
+ vi.mock("node:child_process", () => ({
47
+ exec: vi.fn(),
48
+ }));
49
+
50
+ // Mock readline for interactive commands
51
+ vi.mock("node:readline/promises", () => ({
52
+ createInterface: vi.fn(() => ({
53
+ question: vi.fn().mockResolvedValue("n"),
54
+ close: vi.fn(),
55
+ })),
56
+ }));
57
+
58
+ import {
59
+ formatDuration,
60
+ formatTimeAgo,
61
+ renderBar,
62
+ formatResetTime,
63
+ renderUsageLines,
64
+ refreshAccessToken,
65
+ fetchUsage,
66
+ ensureTokenAndFetchUsage,
67
+ cmdList,
68
+ cmdStatus,
69
+ cmdSwitch,
70
+ cmdEnable,
71
+ cmdDisable,
72
+ cmdLogin,
73
+ cmdLogout,
74
+ cmdRemove,
75
+ cmdReauth,
76
+ cmdRefresh,
77
+ cmdReset,
78
+ cmdStats,
79
+ cmdResetStats,
80
+ cmdConfig,
81
+ cmdHelp,
82
+ main,
83
+ } from "./cli.mjs";
84
+ import { loadAccounts, saveAccounts } from "./lib/storage.mjs";
85
+ import { authorize, exchange, revoke } from "./lib/oauth.mjs";
86
+ import { createInterface } from "node:readline/promises";
87
+ import { exec } from "node:child_process";
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Global fetch mock — prevents real HTTP calls and speeds up tests
91
+ // ---------------------------------------------------------------------------
92
+ const mockFetch = vi.fn();
93
+ vi.stubGlobal("fetch", mockFetch);
94
+
95
+ beforeEach(() => {
96
+ mockFetch.mockReset();
97
+ // Default: all fetches fail gracefully (usage endpoints return null)
98
+ mockFetch.mockResolvedValue({ ok: false, status: 500 });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Helpers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /** Capture console.log and console.error output */
106
+ function captureOutput() {
107
+ const logs = [];
108
+ const errors = [];
109
+ const origLog = console.log;
110
+ const origError = console.error;
111
+
112
+ console.log = (...args) => logs.push(args.join(" "));
113
+ console.error = (...args) => errors.push(args.join(" "));
114
+
115
+ return {
116
+ logs,
117
+ errors,
118
+ /** Get all log output as a single string (ANSI stripped) */
119
+ text: () => logs.join("\n").replace(/\x1b\[[0-9;]*m/g, ""), // eslint-disable-line no-control-regex
120
+ /** Get all error output as a single string (ANSI stripped) */
121
+ errorText: () => errors.join("\n").replace(/\x1b\[[0-9;]*m/g, ""), // eslint-disable-line no-control-regex
122
+ restore: () => {
123
+ console.log = origLog;
124
+ console.error = origError;
125
+ },
126
+ };
127
+ }
128
+
129
+ /** Temporarily set process.stdin.isTTY for interactive command tests. */
130
+ function setStdinTTY(value) {
131
+ const previous = process.stdin.isTTY;
132
+ process.stdin.isTTY = value;
133
+ return () => {
134
+ if (typeof previous === "undefined") {
135
+ delete process.stdin.isTTY;
136
+ } else {
137
+ process.stdin.isTTY = previous;
138
+ }
139
+ };
140
+ }
141
+
142
+ /** Configure mocked readline to return a specific answer. */
143
+ function mockReadlineAnswer(answer) {
144
+ const rl = {
145
+ question: vi.fn().mockResolvedValue(answer),
146
+ close: vi.fn(),
147
+ };
148
+ vi.mocked(createInterface).mockReturnValue(rl);
149
+ return rl;
150
+ }
151
+
152
+ /** Make a standard test account storage object */
153
+ function makeStorage(overrides = {}) {
154
+ return {
155
+ version: 1,
156
+ accounts: [
157
+ {
158
+ email: "alice@example.com",
159
+ refreshToken: "refresh-alice",
160
+ addedAt: 1000,
161
+ lastUsed: 5000,
162
+ enabled: true,
163
+ rateLimitResetTimes: {},
164
+ consecutiveFailures: 0,
165
+ lastFailureTime: null,
166
+ },
167
+ {
168
+ refreshToken: "refresh-bob",
169
+ addedAt: 2000,
170
+ lastUsed: 3000,
171
+ enabled: true,
172
+ rateLimitResetTimes: {},
173
+ consecutiveFailures: 0,
174
+ lastFailureTime: null,
175
+ },
176
+ {
177
+ email: "charlie@example.com",
178
+ refreshToken: "refresh-charlie",
179
+ addedAt: 3000,
180
+ lastUsed: 1000,
181
+ enabled: false,
182
+ rateLimitResetTimes: {},
183
+ consecutiveFailures: 0,
184
+ lastFailureTime: null,
185
+ },
186
+ ],
187
+ activeIndex: 0,
188
+ ...overrides,
189
+ };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Formatting helpers
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("formatDuration", () => {
197
+ it("returns 'now' for zero or negative", () => {
198
+ expect(formatDuration(0)).toBe("now");
199
+ expect(formatDuration(-100)).toBe("now");
200
+ });
201
+
202
+ it("formats seconds", () => {
203
+ expect(formatDuration(5000)).toBe("5s");
204
+ expect(formatDuration(59000)).toBe("59s");
205
+ });
206
+
207
+ it("formats minutes and seconds", () => {
208
+ expect(formatDuration(90_000)).toBe("1m 30s");
209
+ expect(formatDuration(120_000)).toBe("2m");
210
+ });
211
+
212
+ it("formats hours and minutes", () => {
213
+ expect(formatDuration(3_600_000)).toBe("1h");
214
+ expect(formatDuration(5_400_000)).toBe("1h 30m");
215
+ });
216
+
217
+ it("formats long durations as days", () => {
218
+ expect(formatDuration(86_400_000)).toBe("1d");
219
+ expect(formatDuration(112 * 3_600_000)).toBe("4d 16h");
220
+ });
221
+ });
222
+
223
+ describe("formatTimeAgo", () => {
224
+ it("returns 'never' for zero or falsy", () => {
225
+ expect(formatTimeAgo(0)).toBe("never");
226
+ expect(formatTimeAgo(null)).toBe("never");
227
+ expect(formatTimeAgo(undefined)).toBe("never");
228
+ });
229
+
230
+ it("returns relative time for past timestamps", () => {
231
+ const fiveMinAgo = Date.now() - 300_000;
232
+ expect(formatTimeAgo(fiveMinAgo)).toBe("5m ago");
233
+ });
234
+
235
+ it("returns 'just now' for future timestamps", () => {
236
+ expect(formatTimeAgo(Date.now() + 10_000)).toBe("just now");
237
+ });
238
+ });
239
+
240
+ /** Strip ANSI escape codes for test assertions. */
241
+ function stripAnsi(str) {
242
+ return str.replace(/\x1b\[[0-9;]*m/g, ""); // eslint-disable-line no-control-regex
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Usage formatting helpers
247
+ // ---------------------------------------------------------------------------
248
+
249
+ describe("renderBar", () => {
250
+ it("renders empty bar at 0%", () => {
251
+ const bar = stripAnsi(renderBar(0, 10));
252
+ expect(bar).toBe("░".repeat(10));
253
+ });
254
+
255
+ it("renders full bar at 100%", () => {
256
+ const bar = stripAnsi(renderBar(100, 10));
257
+ expect(bar).toBe("█".repeat(10));
258
+ });
259
+
260
+ it("renders proportional fill at 50%", () => {
261
+ const bar = stripAnsi(renderBar(50, 10));
262
+ expect(bar).toBe("█████░░░░░");
263
+ });
264
+
265
+ it("clamps above 100%", () => {
266
+ const bar = stripAnsi(renderBar(150, 10));
267
+ expect(bar).toBe("█".repeat(10));
268
+ });
269
+
270
+ it("clamps below 0%", () => {
271
+ const bar = stripAnsi(renderBar(-10, 10));
272
+ expect(bar).toBe("░".repeat(10));
273
+ });
274
+ });
275
+
276
+ describe("formatResetTime", () => {
277
+ it("returns relative duration for future timestamps", () => {
278
+ const future = new Date(Date.now() + 3600_000).toISOString();
279
+ expect(formatResetTime(future)).toMatch(/59m|1h/);
280
+ });
281
+
282
+ it("returns 'now' for past timestamps", () => {
283
+ const past = new Date(Date.now() - 1000).toISOString();
284
+ expect(formatResetTime(past)).toBe("now");
285
+ });
286
+ });
287
+
288
+ describe("renderUsageLines", () => {
289
+ it("renders lines for non-null buckets only", () => {
290
+ const usage = {
291
+ five_hour: { utilization: 10.0, resets_at: new Date(Date.now() + 3600_000).toISOString() },
292
+ seven_day: { utilization: 67.0, resets_at: new Date(Date.now() + 86400_000).toISOString() },
293
+ seven_day_sonnet: null,
294
+ seven_day_opus: null,
295
+ };
296
+ const lines = renderUsageLines(usage);
297
+ expect(lines).toHaveLength(2);
298
+ const text = lines.map(stripAnsi).join("\n");
299
+ expect(text).toContain("5h");
300
+ expect(text).toContain("10%");
301
+ expect(text).toContain("7d");
302
+ expect(text).toContain("67%");
303
+ });
304
+
305
+ it("returns empty array when all buckets are null", () => {
306
+ const usage = { five_hour: null, seven_day: null };
307
+ expect(renderUsageLines(usage)).toHaveLength(0);
308
+ });
309
+
310
+ it("includes model-specific buckets when present", () => {
311
+ const usage = {
312
+ five_hour: { utilization: 5.0, resets_at: new Date(Date.now() + 1000).toISOString() },
313
+ seven_day: { utilization: 30.0, resets_at: new Date(Date.now() + 1000).toISOString() },
314
+ seven_day_sonnet: { utilization: 11.0, resets_at: new Date(Date.now() + 1000).toISOString() },
315
+ seven_day_opus: { utilization: 22.0, resets_at: new Date(Date.now() + 1000).toISOString() },
316
+ };
317
+ const lines = renderUsageLines(usage);
318
+ expect(lines).toHaveLength(4);
319
+ const text = lines.map(stripAnsi).join("\n");
320
+ expect(text).toContain("Sonnet 7d");
321
+ expect(text).toContain("11%");
322
+ expect(text).toContain("Opus 7d");
323
+ expect(text).toContain("22%");
324
+ });
325
+ });
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Usage fetch helpers
329
+ // ---------------------------------------------------------------------------
330
+
331
+ describe("refreshAccessToken", () => {
332
+ it("refreshes token and updates account object", async () => {
333
+ const account = { refreshToken: "old-refresh", access: undefined, expires: undefined };
334
+ mockFetch.mockResolvedValueOnce({
335
+ ok: true,
336
+ json: async () => ({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }),
337
+ });
338
+
339
+ const token = await refreshAccessToken(account);
340
+ expect(token).toBe("new-access");
341
+ expect(account.access).toBe("new-access");
342
+ expect(account.refreshToken).toBe("new-refresh");
343
+ expect(account.expires).toBeGreaterThan(Date.now());
344
+ });
345
+
346
+ it("returns null on failure", async () => {
347
+ const account = { refreshToken: "bad-refresh" };
348
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
349
+
350
+ const token = await refreshAccessToken(account);
351
+ expect(token).toBeNull();
352
+ });
353
+
354
+ it("returns null on network error", async () => {
355
+ const account = { refreshToken: "refresh" };
356
+ mockFetch.mockRejectedValueOnce(new Error("network error"));
357
+
358
+ const token = await refreshAccessToken(account);
359
+ expect(token).toBeNull();
360
+ });
361
+ });
362
+
363
+ describe("fetchUsage", () => {
364
+ it("returns usage data on success", async () => {
365
+ const usageData = {
366
+ five_hour: { utilization: 10.0, resets_at: "2026-02-07T06:00:00Z" },
367
+ seven_day: { utilization: 67.0, resets_at: "2026-02-08T01:00:00Z" },
368
+ };
369
+ mockFetch.mockResolvedValueOnce({
370
+ ok: true,
371
+ json: async () => usageData,
372
+ });
373
+
374
+ const result = await fetchUsage("valid-token");
375
+ expect(result).toEqual(usageData);
376
+
377
+ const [url, opts] = mockFetch.mock.calls[0];
378
+ expect(url).toBe("https://api.anthropic.com/api/oauth/usage");
379
+ expect(opts.headers.authorization).toBe("Bearer valid-token");
380
+ expect(opts.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
381
+ });
382
+
383
+ it("returns null on failure", async () => {
384
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
385
+ expect(await fetchUsage("bad-token")).toBeNull();
386
+ });
387
+ });
388
+
389
+ describe("ensureTokenAndFetchUsage", () => {
390
+ it("skips disabled accounts", async () => {
391
+ const result = await ensureTokenAndFetchUsage({ enabled: false, refreshToken: "x" });
392
+ expect(result).toEqual({ usage: null, tokenRefreshed: false });
393
+ expect(mockFetch).not.toHaveBeenCalled();
394
+ });
395
+
396
+ it("uses existing valid token without refreshing", async () => {
397
+ const account = {
398
+ enabled: true,
399
+ refreshToken: "refresh",
400
+ access: "valid-access",
401
+ expires: Date.now() + 3600_000,
402
+ };
403
+ const usageData = { five_hour: { utilization: 5.0, resets_at: "2026-01-01T00:00:00Z" } };
404
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => usageData });
405
+
406
+ const result = await ensureTokenAndFetchUsage(account);
407
+ expect(result.usage).toEqual(usageData);
408
+ expect(result.tokenRefreshed).toBe(false);
409
+ // Only 1 fetch call (usage), no token refresh
410
+ expect(mockFetch).toHaveBeenCalledTimes(1);
411
+ });
412
+
413
+ it("refreshes expired token before fetching usage", async () => {
414
+ const account = {
415
+ enabled: true,
416
+ refreshToken: "refresh",
417
+ access: "expired-access",
418
+ expires: Date.now() - 1000, // expired
419
+ };
420
+ // First call: token refresh
421
+ mockFetch.mockResolvedValueOnce({
422
+ ok: true,
423
+ json: async () => ({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }),
424
+ });
425
+ // Second call: usage fetch
426
+ const usageData = { five_hour: { utilization: 20.0, resets_at: "2026-01-01T00:00:00Z" } };
427
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => usageData });
428
+
429
+ const result = await ensureTokenAndFetchUsage(account);
430
+ expect(result.usage).toEqual(usageData);
431
+ expect(result.tokenRefreshed).toBe(true);
432
+ expect(mockFetch).toHaveBeenCalledTimes(2);
433
+ });
434
+
435
+ it("returns null usage when token refresh fails", async () => {
436
+ const account = {
437
+ enabled: true,
438
+ refreshToken: "bad-refresh",
439
+ access: undefined,
440
+ expires: undefined,
441
+ };
442
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
443
+
444
+ const result = await ensureTokenAndFetchUsage(account);
445
+ expect(result.usage).toBeNull();
446
+ expect(result.tokenRefreshed).toBe(false);
447
+ });
448
+ });
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // cmdList
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /** Helper to mock usage fetch for cmdList tests. */
455
+ function mockUsageForAccounts(...usages) {
456
+ const queue = [...usages];
457
+ const usageByToken = new Map();
458
+ let tokenCounter = 0;
459
+
460
+ mockFetch.mockImplementation((url, opts = {}) => {
461
+ const target = String(url);
462
+
463
+ if (target.includes("/v1/oauth/token")) {
464
+ if (queue.length === 0) return Promise.resolve({ ok: false, status: 500 });
465
+
466
+ const usage = queue.shift();
467
+ if (usage === null) {
468
+ return Promise.resolve({ ok: false, status: 401 });
469
+ }
470
+
471
+ tokenCounter += 1;
472
+ const token = `access-${tokenCounter}`;
473
+ usageByToken.set(token, usage);
474
+ return Promise.resolve({
475
+ ok: true,
476
+ json: async () => ({
477
+ access_token: token,
478
+ refresh_token: `refresh-${tokenCounter}`,
479
+ expires_in: 3600,
480
+ }),
481
+ });
482
+ }
483
+
484
+ if (target.includes("/api/oauth/usage")) {
485
+ const auth = opts.headers?.authorization || opts.headers?.Authorization;
486
+ const token = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
487
+
488
+ if (!usageByToken.has(token)) {
489
+ return Promise.resolve({ ok: false, status: 401 });
490
+ }
491
+
492
+ const usage = usageByToken.get(token);
493
+ return Promise.resolve({ ok: true, json: async () => usage });
494
+ }
495
+
496
+ return Promise.resolve({ ok: false, status: 500 });
497
+ });
498
+ }
499
+
500
+ describe("cmdList", () => {
501
+ let output;
502
+
503
+ beforeEach(() => {
504
+ vi.resetAllMocks();
505
+ saveAccounts.mockResolvedValue(undefined);
506
+ output = captureOutput();
507
+ });
508
+
509
+ afterEach(() => {
510
+ output.restore();
511
+ });
512
+
513
+ it("shows 'no accounts' message when storage is empty", async () => {
514
+ loadAccounts.mockResolvedValue(null);
515
+ const code = await cmdList();
516
+ expect(code).toBe(1);
517
+ expect(output.text()).toContain("No accounts configured");
518
+ });
519
+
520
+ it("displays account table with correct columns", async () => {
521
+ loadAccounts.mockResolvedValue(makeStorage());
522
+ const code = await cmdList();
523
+ expect(code).toBe(0);
524
+
525
+ const text = output.text();
526
+ expect(text).toContain("Anthropic Multi-Account Status");
527
+ expect(text).toContain("alice@example.com");
528
+ expect(text).toContain("Account 2");
529
+ expect(text).toContain("charlie@example.com");
530
+ expect(text).toContain("active");
531
+ expect(text).toContain("ready");
532
+ expect(text).toContain("disabled");
533
+ });
534
+
535
+ it("shows enabled/disabled counts", async () => {
536
+ loadAccounts.mockResolvedValue(makeStorage());
537
+ const code = await cmdList();
538
+ expect(code).toBe(0);
539
+
540
+ const text = output.text();
541
+ expect(text).toContain("2 of 3 enabled");
542
+ expect(text).toContain("1 disabled");
543
+ });
544
+
545
+ it("shows rate limit countdown for rate-limited accounts", async () => {
546
+ const storage = makeStorage();
547
+ storage.accounts[1].rateLimitResetTimes = {
548
+ anthropic: Date.now() + 150_000, // 2m 30s from now
549
+ };
550
+ loadAccounts.mockResolvedValue(storage);
551
+
552
+ const code = await cmdList();
553
+ expect(code).toBe(0);
554
+
555
+ const text = output.text();
556
+ expect(text).toMatch(/2m\s+30s/);
557
+ });
558
+
559
+ it("shows consecutive failure count", async () => {
560
+ const storage = makeStorage();
561
+ storage.accounts[0].consecutiveFailures = 5;
562
+ loadAccounts.mockResolvedValue(storage);
563
+
564
+ const code = await cmdList();
565
+ expect(code).toBe(0);
566
+
567
+ const text = output.text();
568
+ expect(text).toContain("5");
569
+ });
570
+
571
+ it("shows strategy name", async () => {
572
+ loadAccounts.mockResolvedValue(makeStorage());
573
+ const code = await cmdList();
574
+ expect(code).toBe(0);
575
+ expect(output.text()).toContain("sticky");
576
+ });
577
+
578
+ it("shows live usage quotas for enabled accounts", async () => {
579
+ loadAccounts.mockResolvedValue(makeStorage());
580
+ const usage = {
581
+ five_hour: { utilization: 9.0, resets_at: new Date(Date.now() + 3600_000).toISOString() },
582
+ seven_day: { utilization: 67.0, resets_at: new Date(Date.now() + 86400_000).toISOString() },
583
+ seven_day_sonnet: { utilization: 11.0, resets_at: new Date(Date.now() + 172800_000).toISOString() },
584
+ seven_day_opus: null,
585
+ };
586
+ // Only 2 enabled accounts need mocking (account 3 is disabled, skips fetch)
587
+ mockUsageForAccounts(usage, usage);
588
+
589
+ const code = await cmdList();
590
+ expect(code).toBe(0);
591
+
592
+ const text = output.text();
593
+ expect(text).toContain("5h");
594
+ expect(text).toContain("9%");
595
+ expect(text).toContain("7d");
596
+ expect(text).toContain("67%");
597
+ expect(text).toContain("Sonnet 7d");
598
+ expect(text).toContain("11%");
599
+ // Opus 7d should NOT appear (null)
600
+ expect(text).not.toContain("Opus 7d");
601
+ });
602
+
603
+ it("shows 'quotas: unavailable' when usage fetch fails", async () => {
604
+ const storage = makeStorage();
605
+ storage.accounts[2].enabled = true; // enable all 3
606
+ loadAccounts.mockResolvedValue(storage);
607
+ // All three accounts: token refresh fails
608
+ mockUsageForAccounts(null, null, null);
609
+
610
+ const code = await cmdList();
611
+ expect(code).toBe(0);
612
+ expect(output.text()).toContain("quotas: unavailable");
613
+ });
614
+
615
+ it("does not show quota lines for disabled accounts", async () => {
616
+ loadAccounts.mockResolvedValue(makeStorage());
617
+ // Account 1 and 2 (enabled) get usage; account 3 is disabled and skips fetch entirely
618
+ mockUsageForAccounts(
619
+ { five_hour: { utilization: 5.0, resets_at: new Date(Date.now() + 1000).toISOString() } },
620
+ { five_hour: { utilization: 15.0, resets_at: new Date(Date.now() + 1000).toISOString() } },
621
+ );
622
+
623
+ const code = await cmdList();
624
+ expect(code).toBe(0);
625
+ const text = output.text();
626
+ // Should see quota lines for enabled accounts
627
+ expect(text).toContain("5%");
628
+ expect(text).toContain("15%");
629
+ // Disabled account (charlie) should not have quota lines — just the status row
630
+ const lines = text.split("\n");
631
+ const charlieIdx = lines.findIndex((l) => l.includes("charlie@example.com"));
632
+ expect(charlieIdx).toBeGreaterThan(-1);
633
+ // Next line after charlie should NOT be a quota line
634
+ const nextLine = lines[charlieIdx + 1] || "";
635
+ expect(nextLine).not.toContain("5h");
636
+ expect(nextLine).not.toContain("quotas:");
637
+ });
638
+
639
+ it("persists refreshed tokens back to disk", async () => {
640
+ loadAccounts.mockResolvedValue(makeStorage());
641
+ saveAccounts.mockResolvedValue(undefined);
642
+ // Only 2 enabled accounts need mocking (account 3 is disabled)
643
+ mockUsageForAccounts(
644
+ { five_hour: { utilization: 1.0, resets_at: new Date(Date.now() + 1000).toISOString() } },
645
+ { five_hour: { utilization: 2.0, resets_at: new Date(Date.now() + 1000).toISOString() } },
646
+ );
647
+
648
+ await cmdList();
649
+ // saveAccounts should be called to persist the refreshed tokens
650
+ expect(saveAccounts).toHaveBeenCalled();
651
+ });
652
+ });
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // cmdStatus
656
+ // ---------------------------------------------------------------------------
657
+
658
+ describe("cmdStatus", () => {
659
+ let output;
660
+
661
+ beforeEach(() => {
662
+ vi.resetAllMocks();
663
+ output = captureOutput();
664
+ });
665
+
666
+ afterEach(() => {
667
+ output.restore();
668
+ });
669
+
670
+ it("shows 'no accounts' for empty storage", async () => {
671
+ loadAccounts.mockResolvedValue(null);
672
+ const code = await cmdStatus();
673
+ expect(code).toBe(1);
674
+ expect(output.text()).toContain("no accounts configured");
675
+ });
676
+
677
+ it("shows compact one-liner with account count", async () => {
678
+ loadAccounts.mockResolvedValue(makeStorage());
679
+ const code = await cmdStatus();
680
+ expect(code).toBe(0);
681
+
682
+ const text = output.text();
683
+ expect(text).toContain("anthropic:");
684
+ expect(text).toContain("3 accounts");
685
+ expect(text).toContain("2 active");
686
+ expect(text).toContain("strategy: sticky");
687
+ expect(text).toContain("next: #1");
688
+ });
689
+
690
+ it("includes rate-limited count when accounts are rate-limited", async () => {
691
+ const storage = makeStorage();
692
+ storage.accounts[0].rateLimitResetTimes = {
693
+ anthropic: Date.now() + 60_000,
694
+ };
695
+ loadAccounts.mockResolvedValue(storage);
696
+
697
+ const code = await cmdStatus();
698
+ expect(code).toBe(0);
699
+ expect(output.text()).toContain("1 rate-limited");
700
+ });
701
+ });
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // cmdSwitch
705
+ // ---------------------------------------------------------------------------
706
+
707
+ describe("cmdSwitch", () => {
708
+ let output;
709
+
710
+ beforeEach(() => {
711
+ vi.resetAllMocks();
712
+ output = captureOutput();
713
+ saveAccounts.mockResolvedValue(undefined);
714
+ });
715
+
716
+ afterEach(() => {
717
+ output.restore();
718
+ });
719
+
720
+ it("switches active account", async () => {
721
+ loadAccounts.mockResolvedValue(makeStorage());
722
+ const code = await cmdSwitch("2");
723
+ expect(code).toBe(0);
724
+ expect(output.text()).toContain("Switched");
725
+ expect(output.text()).toContain("#2");
726
+
727
+ // Verify saveAccounts was called with updated activeIndex
728
+ expect(saveAccounts).toHaveBeenCalledWith(expect.objectContaining({ activeIndex: 1 }));
729
+ });
730
+
731
+ it("rejects invalid account number", async () => {
732
+ loadAccounts.mockResolvedValue(makeStorage());
733
+ const code = await cmdSwitch("99");
734
+ expect(code).toBe(1);
735
+ expect(output.errorText()).toContain("does not exist");
736
+ });
737
+
738
+ it("rejects switching to disabled account", async () => {
739
+ loadAccounts.mockResolvedValue(makeStorage());
740
+ const code = await cmdSwitch("3"); // charlie is disabled
741
+ expect(code).toBe(1);
742
+ expect(output.errorText()).toContain("disabled");
743
+ });
744
+
745
+ it("rejects non-numeric input", async () => {
746
+ const code = await cmdSwitch("abc");
747
+ expect(code).toBe(1);
748
+ expect(output.errorText()).toContain("valid account number");
749
+ });
750
+
751
+ it("rejects when no accounts exist", async () => {
752
+ loadAccounts.mockResolvedValue(null);
753
+ const code = await cmdSwitch("1");
754
+ expect(code).toBe(1);
755
+ expect(output.errorText()).toContain("no accounts");
756
+ });
757
+ });
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // cmdEnable
761
+ // ---------------------------------------------------------------------------
762
+
763
+ describe("cmdEnable", () => {
764
+ let output;
765
+
766
+ beforeEach(() => {
767
+ vi.resetAllMocks();
768
+ output = captureOutput();
769
+ saveAccounts.mockResolvedValue(undefined);
770
+ });
771
+
772
+ afterEach(() => {
773
+ output.restore();
774
+ });
775
+
776
+ it("enables a disabled account", async () => {
777
+ loadAccounts.mockResolvedValue(makeStorage());
778
+ const code = await cmdEnable("3"); // charlie is disabled
779
+ expect(code).toBe(0);
780
+ expect(output.text()).toContain("Enabled");
781
+ expect(output.text()).toContain("charlie@example.com");
782
+
783
+ expect(saveAccounts).toHaveBeenCalledWith(
784
+ expect.objectContaining({
785
+ accounts: expect.arrayContaining([
786
+ expect.objectContaining({
787
+ email: "charlie@example.com",
788
+ enabled: true,
789
+ }),
790
+ ]),
791
+ }),
792
+ );
793
+ });
794
+
795
+ it("is a no-op for already enabled account", async () => {
796
+ loadAccounts.mockResolvedValue(makeStorage());
797
+ const code = await cmdEnable("1");
798
+ expect(code).toBe(0);
799
+ expect(output.text()).toContain("already enabled");
800
+ expect(saveAccounts).not.toHaveBeenCalled();
801
+ });
802
+
803
+ it("rejects invalid account number", async () => {
804
+ loadAccounts.mockResolvedValue(makeStorage());
805
+ const code = await cmdEnable("99");
806
+ expect(code).toBe(1);
807
+ expect(output.errorText()).toContain("does not exist");
808
+ });
809
+ });
810
+
811
+ // ---------------------------------------------------------------------------
812
+ // cmdDisable
813
+ // ---------------------------------------------------------------------------
814
+
815
+ describe("cmdDisable", () => {
816
+ let output;
817
+
818
+ beforeEach(() => {
819
+ vi.resetAllMocks();
820
+ output = captureOutput();
821
+ saveAccounts.mockResolvedValue(undefined);
822
+ });
823
+
824
+ afterEach(() => {
825
+ output.restore();
826
+ });
827
+
828
+ it("disables an enabled account", async () => {
829
+ loadAccounts.mockResolvedValue(makeStorage());
830
+ const code = await cmdDisable("2");
831
+ expect(code).toBe(0);
832
+ expect(output.text()).toContain("Disabled");
833
+ expect(output.text()).toContain("Account 2");
834
+ });
835
+
836
+ it("is a no-op for already disabled account", async () => {
837
+ loadAccounts.mockResolvedValue(makeStorage());
838
+ const code = await cmdDisable("3");
839
+ expect(code).toBe(0);
840
+ expect(output.text()).toContain("already disabled");
841
+ });
842
+
843
+ it("prevents disabling the last enabled account", async () => {
844
+ const storage = makeStorage();
845
+ // Only one enabled account
846
+ storage.accounts = [storage.accounts[0]];
847
+ loadAccounts.mockResolvedValue(storage);
848
+
849
+ const code = await cmdDisable("1");
850
+ expect(code).toBe(1);
851
+ expect(output.errorText()).toContain("last enabled");
852
+ });
853
+
854
+ it("switches active account when disabling the active one (single atomic save)", async () => {
855
+ loadAccounts.mockResolvedValue(makeStorage());
856
+ const code = await cmdDisable("1"); // alice is active
857
+ expect(code).toBe(0);
858
+
859
+ // Should save exactly once (disable + activeIndex adjustment in one write)
860
+ expect(saveAccounts).toHaveBeenCalledTimes(1);
861
+ const saved = saveAccounts.mock.calls[0][0];
862
+ expect(saved.accounts[0].enabled).toBe(false);
863
+ expect(saved.activeIndex).toBe(1); // switched to bob (next enabled)
864
+ });
865
+ });
866
+
867
+ // ---------------------------------------------------------------------------
868
+ // Auth commands
869
+ // ---------------------------------------------------------------------------
870
+
871
+ describe("auth commands", () => {
872
+ let output;
873
+
874
+ beforeEach(() => {
875
+ vi.resetAllMocks();
876
+ output = captureOutput();
877
+ saveAccounts.mockResolvedValue(undefined);
878
+ loadAccounts.mockResolvedValue(makeStorage());
879
+ vi.mocked(authorize).mockResolvedValue({ url: "https://auth.example/authorize", verifier: "pkce-verifier" });
880
+ vi.mocked(exchange).mockResolvedValue({
881
+ type: "success",
882
+ refresh: "refresh-new",
883
+ access: "access-new",
884
+ expires: Date.now() + 3600_000,
885
+ email: "new@example.com",
886
+ });
887
+ vi.mocked(revoke).mockResolvedValue(true);
888
+ });
889
+
890
+ afterEach(() => {
891
+ output.restore();
892
+ });
893
+
894
+ it("cmdLogin rejects non-interactive terminals", async () => {
895
+ const restoreTTY = setStdinTTY(false);
896
+ try {
897
+ const code = await cmdLogin();
898
+ expect(code).toBe(1);
899
+ expect(output.errorText()).toContain("requires an interactive terminal");
900
+ expect(authorize).not.toHaveBeenCalled();
901
+ } finally {
902
+ restoreTTY();
903
+ }
904
+ });
905
+
906
+ it("cmdLogin adds a new account via OAuth", async () => {
907
+ loadAccounts.mockResolvedValue(null);
908
+ const restoreTTY = setStdinTTY(true);
909
+ mockReadlineAnswer("auth-code#state");
910
+
911
+ try {
912
+ const code = await cmdLogin();
913
+ expect(code).toBe(0);
914
+ expect(authorize).toHaveBeenCalledWith("max");
915
+ expect(exchange).toHaveBeenCalledWith("auth-code#state", "pkce-verifier");
916
+ expect(exec).toHaveBeenCalled();
917
+ expect(saveAccounts).toHaveBeenCalledWith(
918
+ expect.objectContaining({
919
+ version: 1,
920
+ activeIndex: 0,
921
+ accounts: expect.arrayContaining([
922
+ expect.objectContaining({
923
+ refreshToken: "refresh-new",
924
+ access: "access-new",
925
+ enabled: true,
926
+ email: "new@example.com",
927
+ }),
928
+ ]),
929
+ }),
930
+ );
931
+ } finally {
932
+ restoreTTY();
933
+ }
934
+ });
935
+
936
+ it("cmdLogin updates duplicate account even when at max capacity", async () => {
937
+ const fullStorage = {
938
+ version: 1,
939
+ activeIndex: 0,
940
+ accounts: Array.from({ length: 10 }, (_, i) => ({
941
+ refreshToken: i === 4 ? "refresh-new" : `refresh-${i}`,
942
+ access: `access-${i}`,
943
+ expires: Date.now() + 1000,
944
+ addedAt: 1000 + i,
945
+ lastUsed: 0,
946
+ enabled: i === 4 ? false : true,
947
+ rateLimitResetTimes: {},
948
+ consecutiveFailures: 2,
949
+ lastFailureTime: Date.now(),
950
+ })),
951
+ };
952
+ loadAccounts.mockResolvedValue(fullStorage);
953
+ const restoreTTY = setStdinTTY(true);
954
+ mockReadlineAnswer("auth-code#state");
955
+
956
+ try {
957
+ const code = await cmdLogin();
958
+ expect(code).toBe(0);
959
+ const saved = saveAccounts.mock.calls[0][0];
960
+ expect(saved.accounts).toHaveLength(10);
961
+ expect(saved.accounts[4].refreshToken).toBe("refresh-new");
962
+ expect(saved.accounts[4].access).toBe("access-new");
963
+ expect(saved.accounts[4].enabled).toBe(true);
964
+ } finally {
965
+ restoreTTY();
966
+ }
967
+ });
968
+
969
+ it("cmdLogin rejects adding new account when at max capacity", async () => {
970
+ const fullStorage = {
971
+ version: 1,
972
+ activeIndex: 0,
973
+ accounts: Array.from({ length: 10 }, (_, i) => ({
974
+ refreshToken: `refresh-${i}`,
975
+ access: `access-${i}`,
976
+ expires: Date.now() + 1000,
977
+ addedAt: 1000 + i,
978
+ lastUsed: 0,
979
+ enabled: true,
980
+ rateLimitResetTimes: {},
981
+ consecutiveFailures: 0,
982
+ lastFailureTime: null,
983
+ })),
984
+ };
985
+ loadAccounts.mockResolvedValue(fullStorage);
986
+ const restoreTTY = setStdinTTY(true);
987
+ mockReadlineAnswer("auth-code#state");
988
+
989
+ try {
990
+ const code = await cmdLogin();
991
+ expect(code).toBe(1);
992
+ expect(output.errorText()).toContain("maximum of 10 accounts reached");
993
+ expect(saveAccounts).not.toHaveBeenCalled();
994
+ } finally {
995
+ restoreTTY();
996
+ }
997
+ });
998
+
999
+ it("cmdLogout removes one account and revokes token", async () => {
1000
+ loadAccounts.mockResolvedValue(makeStorage());
1001
+
1002
+ const code = await cmdLogout("2", { force: true });
1003
+ expect(code).toBe(0);
1004
+ expect(revoke).toHaveBeenCalledWith("refresh-bob");
1005
+ const saved = saveAccounts.mock.calls[0][0];
1006
+ expect(saved.accounts).toHaveLength(2);
1007
+ expect(saved.accounts.find((a) => a.refreshToken === "refresh-bob")).toBeUndefined();
1008
+ });
1009
+
1010
+ it("cmdLogout --all revokes all accounts and writes explicit empty storage", async () => {
1011
+ loadAccounts.mockResolvedValue(makeStorage());
1012
+
1013
+ const code = await cmdLogout(undefined, { all: true, force: true });
1014
+ expect(code).toBe(0);
1015
+ expect(revoke).toHaveBeenCalledTimes(3);
1016
+ expect(saveAccounts).toHaveBeenCalledWith({ version: 1, accounts: [], activeIndex: 0 });
1017
+ });
1018
+
1019
+ it("cmdReauth refreshes credentials and resets account failure state", async () => {
1020
+ const storage = makeStorage();
1021
+ storage.accounts[0].enabled = false;
1022
+ storage.accounts[0].consecutiveFailures = 5;
1023
+ storage.accounts[0].lastFailureTime = Date.now();
1024
+ storage.accounts[0].rateLimitResetTimes = { anthropic: Date.now() + 60_000 };
1025
+ loadAccounts.mockResolvedValue(storage);
1026
+ vi.mocked(exchange).mockResolvedValueOnce({
1027
+ type: "success",
1028
+ refresh: "refresh-reauth",
1029
+ access: "access-reauth",
1030
+ expires: Date.now() + 7200_000,
1031
+ email: "alice+reauth@example.com",
1032
+ });
1033
+
1034
+ const restoreTTY = setStdinTTY(true);
1035
+ mockReadlineAnswer("reauth-code#state");
1036
+
1037
+ try {
1038
+ const code = await cmdReauth("1");
1039
+ expect(code).toBe(0);
1040
+ const saved = saveAccounts.mock.calls[0][0];
1041
+ expect(saved.accounts[0]).toEqual(
1042
+ expect.objectContaining({
1043
+ refreshToken: "refresh-reauth",
1044
+ access: "access-reauth",
1045
+ email: "alice+reauth@example.com",
1046
+ enabled: true,
1047
+ consecutiveFailures: 0,
1048
+ lastFailureTime: null,
1049
+ rateLimitResetTimes: {},
1050
+ }),
1051
+ );
1052
+ expect(output.text()).toContain("re-enabled");
1053
+ } finally {
1054
+ restoreTTY();
1055
+ }
1056
+ });
1057
+
1058
+ it("cmdRefresh updates tokens and re-enables account", async () => {
1059
+ const storage = makeStorage();
1060
+ storage.accounts[2].enabled = false;
1061
+ storage.accounts[2].consecutiveFailures = 4;
1062
+ storage.accounts[2].lastFailureTime = Date.now();
1063
+ storage.accounts[2].rateLimitResetTimes = { anthropic: Date.now() + 30_000 };
1064
+ loadAccounts.mockResolvedValue(storage);
1065
+ mockFetch.mockResolvedValueOnce({
1066
+ ok: true,
1067
+ json: async () => ({ access_token: "fresh-access", refresh_token: "fresh-refresh", expires_in: 3600 }),
1068
+ });
1069
+
1070
+ const code = await cmdRefresh("3");
1071
+ expect(code).toBe(0);
1072
+ const saved = saveAccounts.mock.calls[0][0];
1073
+ expect(saved.accounts[2]).toEqual(
1074
+ expect.objectContaining({
1075
+ access: "fresh-access",
1076
+ refreshToken: "fresh-refresh",
1077
+ enabled: true,
1078
+ consecutiveFailures: 0,
1079
+ lastFailureTime: null,
1080
+ rateLimitResetTimes: {},
1081
+ }),
1082
+ );
1083
+ expect(output.text()).toContain("Token refreshed");
1084
+ expect(output.text()).toContain("re-enabled");
1085
+ });
1086
+
1087
+ it("cmdRefresh suggests reauth when refresh fails", async () => {
1088
+ loadAccounts.mockResolvedValue(makeStorage());
1089
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
1090
+
1091
+ const code = await cmdRefresh("1");
1092
+ expect(code).toBe(1);
1093
+ expect(output.errorText()).toContain("Try: opencode-anthropic-auth reauth 1");
1094
+ expect(saveAccounts).not.toHaveBeenCalled();
1095
+ });
1096
+ });
1097
+
1098
+ // ---------------------------------------------------------------------------
1099
+ // cmdRemove
1100
+ // ---------------------------------------------------------------------------
1101
+
1102
+ describe("cmdRemove", () => {
1103
+ let output;
1104
+
1105
+ beforeEach(() => {
1106
+ vi.resetAllMocks();
1107
+ output = captureOutput();
1108
+ saveAccounts.mockResolvedValue(undefined);
1109
+ });
1110
+
1111
+ afterEach(() => {
1112
+ output.restore();
1113
+ });
1114
+
1115
+ it("removes account with --force (no confirmation)", async () => {
1116
+ loadAccounts.mockResolvedValue(makeStorage());
1117
+ const code = await cmdRemove("2", { force: true });
1118
+ expect(code).toBe(0);
1119
+ expect(output.text()).toContain("Removed");
1120
+ expect(output.text()).toContain("Account 2");
1121
+
1122
+ expect(saveAccounts).toHaveBeenCalledWith(
1123
+ expect.objectContaining({
1124
+ accounts: expect.arrayContaining([
1125
+ expect.objectContaining({ email: "alice@example.com" }),
1126
+ expect.objectContaining({ email: "charlie@example.com" }),
1127
+ ]),
1128
+ }),
1129
+ );
1130
+ // Should have 2 accounts remaining (alice + charlie)
1131
+ const saved = saveAccounts.mock.calls[0][0];
1132
+ expect(saved.accounts).toHaveLength(2);
1133
+ });
1134
+
1135
+ it("adjusts activeIndex when removing account before active", async () => {
1136
+ const storage = makeStorage({ activeIndex: 2 }); // charlie is active
1137
+ loadAccounts.mockResolvedValue(storage);
1138
+
1139
+ const code = await cmdRemove("1", { force: true }); // remove alice (before active)
1140
+ expect(code).toBe(0);
1141
+
1142
+ const saved = saveAccounts.mock.calls[0][0];
1143
+ expect(saved.activeIndex).toBe(1); // shifted down by 1
1144
+ });
1145
+
1146
+ it("resets activeIndex when removing last account", async () => {
1147
+ const storage = makeStorage();
1148
+ storage.accounts = [storage.accounts[0]];
1149
+ storage.activeIndex = 0;
1150
+ loadAccounts.mockResolvedValue(storage);
1151
+
1152
+ const code = await cmdRemove("1", { force: true });
1153
+ expect(code).toBe(0);
1154
+
1155
+ const saved = saveAccounts.mock.calls[0][0];
1156
+ expect(saved.accounts).toHaveLength(0);
1157
+ expect(saved.activeIndex).toBe(0);
1158
+ expect(output.text()).toContain("No accounts remaining");
1159
+ });
1160
+
1161
+ it("rejects invalid account number", async () => {
1162
+ loadAccounts.mockResolvedValue(makeStorage());
1163
+ const code = await cmdRemove("99", { force: true });
1164
+ expect(code).toBe(1);
1165
+ expect(output.errorText()).toContain("does not exist");
1166
+ });
1167
+ });
1168
+
1169
+ // ---------------------------------------------------------------------------
1170
+ // cmdReset
1171
+ // ---------------------------------------------------------------------------
1172
+
1173
+ describe("cmdReset", () => {
1174
+ let output;
1175
+
1176
+ beforeEach(() => {
1177
+ vi.resetAllMocks();
1178
+ output = captureOutput();
1179
+ saveAccounts.mockResolvedValue(undefined);
1180
+ });
1181
+
1182
+ afterEach(() => {
1183
+ output.restore();
1184
+ });
1185
+
1186
+ it("resets tracking for a single account", async () => {
1187
+ const storage = makeStorage();
1188
+ storage.accounts[0].consecutiveFailures = 5;
1189
+ storage.accounts[0].lastFailureTime = Date.now();
1190
+ storage.accounts[0].rateLimitResetTimes = { anthropic: Date.now() + 60_000 };
1191
+ loadAccounts.mockResolvedValue(storage);
1192
+
1193
+ const code = await cmdReset("1");
1194
+ expect(code).toBe(0);
1195
+ expect(output.text()).toContain("Reset tracking");
1196
+ expect(output.text()).toContain("alice@example.com");
1197
+
1198
+ const saved = saveAccounts.mock.calls[0][0];
1199
+ expect(saved.accounts[0].consecutiveFailures).toBe(0);
1200
+ expect(saved.accounts[0].lastFailureTime).toBeNull();
1201
+ expect(saved.accounts[0].rateLimitResetTimes).toEqual({});
1202
+ });
1203
+
1204
+ it("resets tracking for all accounts", async () => {
1205
+ const storage = makeStorage();
1206
+ storage.accounts[0].consecutiveFailures = 3;
1207
+ storage.accounts[1].consecutiveFailures = 7;
1208
+ loadAccounts.mockResolvedValue(storage);
1209
+
1210
+ const code = await cmdReset("all");
1211
+ expect(code).toBe(0);
1212
+ expect(output.text()).toContain("all 3 account(s)");
1213
+
1214
+ const saved = saveAccounts.mock.calls[0][0];
1215
+ for (const acc of saved.accounts) {
1216
+ expect(acc.consecutiveFailures).toBe(0);
1217
+ expect(acc.lastFailureTime).toBeNull();
1218
+ expect(acc.rateLimitResetTimes).toEqual({});
1219
+ }
1220
+ });
1221
+
1222
+ it("rejects missing argument", async () => {
1223
+ const code = await cmdReset(undefined);
1224
+ expect(code).toBe(1);
1225
+ expect(output.errorText()).toContain("provide an account number");
1226
+ });
1227
+
1228
+ it("rejects invalid account number", async () => {
1229
+ loadAccounts.mockResolvedValue(makeStorage());
1230
+ const code = await cmdReset("99");
1231
+ expect(code).toBe(1);
1232
+ expect(output.errorText()).toContain("does not exist");
1233
+ });
1234
+ });
1235
+
1236
+ // ---------------------------------------------------------------------------
1237
+ // cmdConfig
1238
+ // ---------------------------------------------------------------------------
1239
+
1240
+ describe("cmdConfig", () => {
1241
+ let output;
1242
+
1243
+ beforeEach(() => {
1244
+ vi.resetAllMocks();
1245
+ output = captureOutput();
1246
+ });
1247
+
1248
+ afterEach(() => {
1249
+ output.restore();
1250
+ });
1251
+
1252
+ it("displays configuration values", async () => {
1253
+ loadAccounts.mockResolvedValue(makeStorage());
1254
+ const code = await cmdConfig();
1255
+ expect(code).toBe(0);
1256
+
1257
+ const text = output.text();
1258
+ expect(text).toContain("Anthropic Auth Configuration");
1259
+ expect(text).toContain("sticky");
1260
+ expect(text).toContain("3600s");
1261
+ expect(text).toContain("off"); // debug
1262
+ });
1263
+
1264
+ it("shows health score config", async () => {
1265
+ loadAccounts.mockResolvedValue(null);
1266
+ const code = await cmdConfig();
1267
+ expect(code).toBe(0);
1268
+
1269
+ const text = output.text();
1270
+ expect(text).toContain("Health Score");
1271
+ expect(text).toContain("70"); // initial
1272
+ expect(text).toContain("+1"); // success_reward
1273
+ expect(text).toContain("-10"); // rate_limit_penalty
1274
+ });
1275
+
1276
+ it("shows token bucket config", async () => {
1277
+ loadAccounts.mockResolvedValue(null);
1278
+ const code = await cmdConfig();
1279
+ expect(code).toBe(0);
1280
+
1281
+ const text = output.text();
1282
+ expect(text).toContain("Token Bucket");
1283
+ expect(text).toContain("50"); // max_tokens
1284
+ });
1285
+
1286
+ it("shows file paths", async () => {
1287
+ loadAccounts.mockResolvedValue(null);
1288
+ const code = await cmdConfig();
1289
+ expect(code).toBe(0);
1290
+
1291
+ const text = output.text();
1292
+ expect(text).toContain("anthropic-auth.json");
1293
+ expect(text).toContain("anthropic-accounts.json");
1294
+ });
1295
+
1296
+ it("shows account count when accounts exist", async () => {
1297
+ loadAccounts.mockResolvedValue(makeStorage());
1298
+ const code = await cmdConfig();
1299
+ expect(code).toBe(0);
1300
+ expect(output.text()).toContain("3 (2 enabled)");
1301
+ });
1302
+
1303
+ it("shows 'none' when no accounts exist", async () => {
1304
+ loadAccounts.mockResolvedValue(null);
1305
+ const code = await cmdConfig();
1306
+ expect(code).toBe(0);
1307
+ expect(output.text()).toContain("none");
1308
+ });
1309
+ });
1310
+
1311
+ // ---------------------------------------------------------------------------
1312
+ // cmdHelp
1313
+ // ---------------------------------------------------------------------------
1314
+
1315
+ describe("cmdHelp", () => {
1316
+ let output;
1317
+
1318
+ beforeEach(() => {
1319
+ output = captureOutput();
1320
+ });
1321
+
1322
+ afterEach(() => {
1323
+ output.restore();
1324
+ });
1325
+
1326
+ it("shows all commands", () => {
1327
+ cmdHelp();
1328
+ const text = output.text();
1329
+ expect(text).toContain("login");
1330
+ expect(text).toContain("logout");
1331
+ expect(text).toContain("reauth");
1332
+ expect(text).toContain("refresh");
1333
+ expect(text).toContain("list");
1334
+ expect(text).toContain("status");
1335
+ expect(text).toContain("switch");
1336
+ expect(text).toContain("enable");
1337
+ expect(text).toContain("disable");
1338
+ expect(text).toContain("remove");
1339
+ expect(text).toContain("reset");
1340
+ expect(text).toContain("config");
1341
+ expect(text).toContain("manage");
1342
+ expect(text).toContain("help");
1343
+ });
1344
+
1345
+ it("shows examples", () => {
1346
+ cmdHelp();
1347
+ const text = output.text();
1348
+ expect(text).toContain("logout --all");
1349
+ expect(text).toContain("reauth 1");
1350
+ expect(text).toContain("switch 2");
1351
+ expect(text).toContain("disable 3");
1352
+ expect(text).toContain("reset all");
1353
+ });
1354
+ });
1355
+
1356
+ // ---------------------------------------------------------------------------
1357
+ // main() routing
1358
+ // ---------------------------------------------------------------------------
1359
+
1360
+ describe("main routing", () => {
1361
+ let output;
1362
+
1363
+ beforeEach(() => {
1364
+ vi.resetAllMocks();
1365
+ output = captureOutput();
1366
+ loadAccounts.mockResolvedValue(makeStorage());
1367
+ saveAccounts.mockResolvedValue(undefined);
1368
+ vi.mocked(authorize).mockResolvedValue({ url: "https://auth.example/authorize", verifier: "pkce-verifier" });
1369
+ vi.mocked(exchange).mockResolvedValue({
1370
+ type: "success",
1371
+ refresh: "refresh-new",
1372
+ access: "access-new",
1373
+ expires: Date.now() + 3600_000,
1374
+ email: "new@example.com",
1375
+ });
1376
+ vi.mocked(revoke).mockResolvedValue(true);
1377
+ });
1378
+
1379
+ afterEach(() => {
1380
+ output.restore();
1381
+ });
1382
+
1383
+ it("defaults to list when no command given", async () => {
1384
+ const code = await main([]);
1385
+ expect(code).toBe(0);
1386
+ expect(output.text()).toContain("Anthropic Multi-Account Status");
1387
+ });
1388
+
1389
+ it("routes 'ls' alias to list", async () => {
1390
+ const code = await main(["ls"]);
1391
+ expect(code).toBe(0);
1392
+ expect(output.text()).toContain("Anthropic Multi-Account Status");
1393
+ });
1394
+
1395
+ it("routes 'st' alias to status", async () => {
1396
+ const code = await main(["st"]);
1397
+ expect(code).toBe(0);
1398
+ expect(output.text()).toContain("anthropic:");
1399
+ });
1400
+
1401
+ it("routes 'sw' alias to switch", async () => {
1402
+ const code = await main(["sw", "2"]);
1403
+ expect(code).toBe(0);
1404
+ expect(output.text()).toContain("Switched");
1405
+ });
1406
+
1407
+ it("routes 'en' alias to enable", async () => {
1408
+ const code = await main(["en", "3"]);
1409
+ expect(code).toBe(0);
1410
+ expect(output.text()).toContain("Enabled");
1411
+ });
1412
+
1413
+ it("routes 'rm' alias to remove with --force", async () => {
1414
+ const code = await main(["rm", "2", "--force"]);
1415
+ expect(code).toBe(0);
1416
+ expect(output.text()).toContain("Removed");
1417
+ });
1418
+
1419
+ it("routes 'cfg' alias to config", async () => {
1420
+ const code = await main(["cfg"]);
1421
+ expect(code).toBe(0);
1422
+ expect(output.text()).toContain("Anthropic Auth Configuration");
1423
+ });
1424
+
1425
+ it("returns error for unknown command", async () => {
1426
+ const code = await main(["foobar"]);
1427
+ expect(code).toBe(1);
1428
+ expect(output.errorText()).toContain("Unknown command");
1429
+ });
1430
+
1431
+ it("routes -h to help", async () => {
1432
+ const code = await main(["-h"]);
1433
+ expect(code).toBe(0);
1434
+ expect(output.text()).toContain("Anthropic Multi-Account Auth CLI");
1435
+ });
1436
+
1437
+ it("routes --help to help", async () => {
1438
+ const code = await main(["--help"]);
1439
+ expect(code).toBe(0);
1440
+ expect(output.text()).toContain("Anthropic Multi-Account Auth CLI");
1441
+ });
1442
+
1443
+ it("strips --flags from args before routing", async () => {
1444
+ const code = await main(["switch", "1", "--force", "--no-color"]);
1445
+ expect(code).toBe(0);
1446
+ // Should have routed to switch with arg "1", not "--force"
1447
+ expect(output.text()).toContain("Switched");
1448
+ });
1449
+
1450
+ it("routes 'dis' alias to disable", async () => {
1451
+ const code = await main(["dis", "2"]);
1452
+ expect(code).toBe(0);
1453
+ expect(output.text()).toContain("Disabled");
1454
+ });
1455
+
1456
+ it("routes 'ln' alias to login", async () => {
1457
+ loadAccounts.mockResolvedValue(null);
1458
+ const restoreTTY = setStdinTTY(true);
1459
+ mockReadlineAnswer("auth-code#state");
1460
+ try {
1461
+ const code = await main(["ln"]);
1462
+ expect(code).toBe(0);
1463
+ expect(authorize).toHaveBeenCalledWith("max");
1464
+ expect(exchange).toHaveBeenCalled();
1465
+ } finally {
1466
+ restoreTTY();
1467
+ }
1468
+ });
1469
+
1470
+ it("routes 'lo --all --force' alias to logout all", async () => {
1471
+ const code = await main(["lo", "--all", "--force"]);
1472
+ expect(code).toBe(0);
1473
+ expect(revoke).toHaveBeenCalled();
1474
+ expect(saveAccounts).toHaveBeenCalledWith({ version: 1, accounts: [], activeIndex: 0 });
1475
+ });
1476
+
1477
+ it("routes 'ra' alias to reauth", async () => {
1478
+ const restoreTTY = setStdinTTY(true);
1479
+ mockReadlineAnswer("reauth-code#state");
1480
+ try {
1481
+ const code = await main(["ra", "1"]);
1482
+ expect(code).toBe(0);
1483
+ expect(exchange).toHaveBeenCalled();
1484
+ expect(output.text()).toContain("Re-authenticated");
1485
+ } finally {
1486
+ restoreTTY();
1487
+ }
1488
+ });
1489
+
1490
+ it("routes 'rf' alias to refresh", async () => {
1491
+ mockFetch.mockResolvedValueOnce({
1492
+ ok: true,
1493
+ json: async () => ({ access_token: "fresh-access", refresh_token: "fresh-refresh", expires_in: 3600 }),
1494
+ });
1495
+ const code = await main(["rf", "1"]);
1496
+ expect(code).toBe(0);
1497
+ expect(output.text()).toContain("Token refreshed");
1498
+ });
1499
+
1500
+ it("supports integration io capture option", async () => {
1501
+ // This test validates IO redirection itself, so disable outer capture hook.
1502
+ output.restore();
1503
+
1504
+ const logs = [];
1505
+ const errors = [];
1506
+ const code = await main(["status"], {
1507
+ io: {
1508
+ log: (...args) => logs.push(args.join(" ")),
1509
+ error: (...args) => errors.push(args.join(" ")),
1510
+ },
1511
+ });
1512
+
1513
+ expect(code).toBe(0);
1514
+ expect(errors).toHaveLength(0);
1515
+ expect(logs.join("\n")).toContain("anthropic:");
1516
+ });
1517
+ });
1518
+
1519
+ // ---------------------------------------------------------------------------
1520
+ // Edge cases: missing arguments
1521
+ // ---------------------------------------------------------------------------
1522
+
1523
+ describe("missing argument handling", () => {
1524
+ let output;
1525
+
1526
+ beforeEach(() => {
1527
+ vi.resetAllMocks();
1528
+ output = captureOutput();
1529
+ loadAccounts.mockResolvedValue(makeStorage());
1530
+ saveAccounts.mockResolvedValue(undefined);
1531
+ });
1532
+
1533
+ afterEach(() => {
1534
+ output.restore();
1535
+ });
1536
+
1537
+ it("cmdSwitch rejects undefined arg", async () => {
1538
+ const code = await cmdSwitch(undefined);
1539
+ expect(code).toBe(1);
1540
+ expect(output.errorText()).toContain("valid account number");
1541
+ });
1542
+
1543
+ it("cmdEnable rejects undefined arg", async () => {
1544
+ const code = await cmdEnable(undefined);
1545
+ expect(code).toBe(1);
1546
+ expect(output.errorText()).toContain("valid account number");
1547
+ });
1548
+
1549
+ it("cmdDisable rejects undefined arg", async () => {
1550
+ const code = await cmdDisable(undefined);
1551
+ expect(code).toBe(1);
1552
+ expect(output.errorText()).toContain("valid account number");
1553
+ });
1554
+
1555
+ it("cmdRemove rejects undefined arg", async () => {
1556
+ const code = await cmdRemove(undefined, { force: true });
1557
+ expect(code).toBe(1);
1558
+ expect(output.errorText()).toContain("valid account number");
1559
+ });
1560
+ });
1561
+
1562
+ // ---------------------------------------------------------------------------
1563
+ // Case insensitivity
1564
+ // ---------------------------------------------------------------------------
1565
+
1566
+ describe("case insensitivity", () => {
1567
+ let output;
1568
+
1569
+ beforeEach(() => {
1570
+ vi.resetAllMocks();
1571
+ output = captureOutput();
1572
+ saveAccounts.mockResolvedValue(undefined);
1573
+ });
1574
+
1575
+ afterEach(() => {
1576
+ output.restore();
1577
+ });
1578
+
1579
+ it("cmdReset accepts 'ALL' (uppercase)", async () => {
1580
+ const storage = makeStorage();
1581
+ storage.accounts[0].consecutiveFailures = 3;
1582
+ loadAccounts.mockResolvedValue(storage);
1583
+
1584
+ const code = await cmdReset("ALL");
1585
+ expect(code).toBe(0);
1586
+ expect(output.text()).toContain("all 3 account(s)");
1587
+ });
1588
+
1589
+ it("cmdReset accepts 'All' (mixed case)", async () => {
1590
+ const storage = makeStorage();
1591
+ loadAccounts.mockResolvedValue(storage);
1592
+
1593
+ const code = await cmdReset("All");
1594
+ expect(code).toBe(0);
1595
+ expect(output.text()).toContain("all 3 account(s)");
1596
+ });
1597
+ });
1598
+
1599
+ // ---------------------------------------------------------------------------
1600
+ // cmdRemove: active account removal
1601
+ // ---------------------------------------------------------------------------
1602
+
1603
+ describe("cmdRemove active account", () => {
1604
+ let output;
1605
+
1606
+ beforeEach(() => {
1607
+ vi.resetAllMocks();
1608
+ output = captureOutput();
1609
+ saveAccounts.mockResolvedValue(undefined);
1610
+ });
1611
+
1612
+ afterEach(() => {
1613
+ output.restore();
1614
+ });
1615
+
1616
+ it("adjusts activeIndex when removing the active account", async () => {
1617
+ const storage = makeStorage({ activeIndex: 1 }); // bob is active
1618
+ loadAccounts.mockResolvedValue(storage);
1619
+
1620
+ const code = await cmdRemove("2", { force: true }); // remove bob
1621
+ expect(code).toBe(0);
1622
+
1623
+ const saved = saveAccounts.mock.calls[0][0];
1624
+ // activeIndex was 1, we removed index 1, so it should clamp to length-1 = 1
1625
+ // (now pointing to charlie, the new index 1)
1626
+ expect(saved.activeIndex).toBe(1);
1627
+ expect(saved.accounts).toHaveLength(2);
1628
+ });
1629
+ });
1630
+
1631
+ // ---------------------------------------------------------------------------
1632
+ // cmdStats
1633
+ // ---------------------------------------------------------------------------
1634
+
1635
+ describe("cmdStats", () => {
1636
+ let output;
1637
+
1638
+ beforeEach(() => {
1639
+ output = captureOutput();
1640
+ vi.clearAllMocks();
1641
+ });
1642
+
1643
+ afterEach(() => {
1644
+ output.restore();
1645
+ });
1646
+
1647
+ function makeStatsStorage() {
1648
+ const storage = makeStorage();
1649
+ storage.accounts[0].stats = {
1650
+ requests: 142,
1651
+ inputTokens: 1_200_000,
1652
+ outputTokens: 380_000,
1653
+ cacheReadTokens: 890_000,
1654
+ cacheWriteTokens: 45_000,
1655
+ lastReset: Date.now() - 86400_000,
1656
+ };
1657
+ storage.accounts[1].email = "bob@example.com";
1658
+ storage.accounts[1].stats = {
1659
+ requests: 87,
1660
+ inputTokens: 720_000,
1661
+ outputTokens: 210_000,
1662
+ cacheReadTokens: 540_000,
1663
+ cacheWriteTokens: 32_000,
1664
+ lastReset: Date.now() - 86400_000,
1665
+ };
1666
+ return storage;
1667
+ }
1668
+
1669
+ it("displays per-account usage statistics", async () => {
1670
+ loadAccounts.mockResolvedValue(makeStatsStorage());
1671
+ const code = await cmdStats();
1672
+ expect(code).toBe(0);
1673
+ const text = output.text();
1674
+ expect(text).toContain("alice@example.com");
1675
+ expect(text).toContain("bob@example.com");
1676
+ expect(text).toContain("142");
1677
+ expect(text).toContain("87");
1678
+ expect(text).toContain("1.2M");
1679
+ expect(text).toContain("Total");
1680
+ });
1681
+
1682
+ it("returns 1 when no accounts configured", async () => {
1683
+ loadAccounts.mockResolvedValue(null);
1684
+ const code = await cmdStats();
1685
+ expect(code).toBe(1);
1686
+ const text = output.text();
1687
+ expect(text).toContain("No accounts");
1688
+ });
1689
+
1690
+ it("handles accounts with no stats (defaults)", async () => {
1691
+ const storage = makeStorage();
1692
+ storage.accounts = [storage.accounts[0]]; // single account, no stats field
1693
+ loadAccounts.mockResolvedValue(storage);
1694
+ const code = await cmdStats();
1695
+ expect(code).toBe(0);
1696
+ const text = output.text();
1697
+ expect(text).toContain("alice@example.com");
1698
+ expect(text).toContain("0");
1699
+ });
1700
+ });
1701
+
1702
+ // ---------------------------------------------------------------------------
1703
+ // cmdResetStats
1704
+ // ---------------------------------------------------------------------------
1705
+
1706
+ describe("cmdResetStats", () => {
1707
+ let output;
1708
+
1709
+ beforeEach(() => {
1710
+ output = captureOutput();
1711
+ vi.clearAllMocks();
1712
+ saveAccounts.mockResolvedValue(undefined);
1713
+ });
1714
+
1715
+ afterEach(() => {
1716
+ output.restore();
1717
+ });
1718
+
1719
+ it("resets stats for all accounts", async () => {
1720
+ const storage = makeStorage();
1721
+ storage.accounts = [storage.accounts[0]];
1722
+ storage.accounts[0].stats = {
1723
+ requests: 100,
1724
+ inputTokens: 50000,
1725
+ outputTokens: 20000,
1726
+ cacheReadTokens: 0,
1727
+ cacheWriteTokens: 0,
1728
+ lastReset: 1000,
1729
+ };
1730
+ loadAccounts.mockResolvedValue(storage);
1731
+
1732
+ const code = await cmdResetStats("all");
1733
+ expect(code).toBe(0);
1734
+ const text = output.text();
1735
+ expect(text).toContain("Reset usage statistics for all");
1736
+
1737
+ const saved = saveAccounts.mock.calls[0][0];
1738
+ expect(saved.accounts[0].stats.requests).toBe(0);
1739
+ expect(saved.accounts[0].stats.inputTokens).toBe(0);
1740
+ });
1741
+
1742
+ it("resets stats for a single account", async () => {
1743
+ const storage = makeStorage();
1744
+ storage.accounts = [storage.accounts[0]];
1745
+ storage.accounts[0].stats = {
1746
+ requests: 100,
1747
+ inputTokens: 50000,
1748
+ outputTokens: 20000,
1749
+ cacheReadTokens: 0,
1750
+ cacheWriteTokens: 0,
1751
+ lastReset: 1000,
1752
+ };
1753
+ loadAccounts.mockResolvedValue(storage);
1754
+
1755
+ const code = await cmdResetStats("1");
1756
+ expect(code).toBe(0);
1757
+ const text = output.text();
1758
+ expect(text).toContain("Reset usage statistics for alice@example.com");
1759
+ });
1760
+
1761
+ it("returns 1 for invalid account number", async () => {
1762
+ const storage = makeStorage();
1763
+ storage.accounts = [storage.accounts[0]];
1764
+ loadAccounts.mockResolvedValue(storage);
1765
+
1766
+ const code = await cmdResetStats("99");
1767
+ expect(code).toBe(1);
1768
+ });
1769
+
1770
+ it("resets all accounts when no argument given", async () => {
1771
+ const storage = makeStorage();
1772
+ storage.accounts = [storage.accounts[0]];
1773
+ storage.accounts[0].stats = {
1774
+ requests: 50,
1775
+ inputTokens: 10000,
1776
+ outputTokens: 5000,
1777
+ cacheReadTokens: 0,
1778
+ cacheWriteTokens: 0,
1779
+ lastReset: 1000,
1780
+ };
1781
+ loadAccounts.mockResolvedValue(storage);
1782
+
1783
+ const code = await cmdResetStats();
1784
+ expect(code).toBe(0);
1785
+ const text = output.text();
1786
+ expect(text).toContain("Reset usage statistics for all");
1787
+
1788
+ const saved = saveAccounts.mock.calls[0][0];
1789
+ expect(saved.accounts[0].stats.requests).toBe(0);
1790
+ });
1791
+
1792
+ it("returns 1 when no accounts configured", async () => {
1793
+ loadAccounts.mockResolvedValue(null);
1794
+ const code = await cmdResetStats("all");
1795
+ expect(code).toBe(1);
1796
+ });
1797
+ });