openusage 0.1.0

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 (58) hide show
  1. package/bin/openusage +91 -0
  2. package/package.json +33 -0
  3. package/plugins/amp/icon.svg +6 -0
  4. package/plugins/amp/plugin.js +175 -0
  5. package/plugins/amp/plugin.json +20 -0
  6. package/plugins/amp/plugin.test.js +365 -0
  7. package/plugins/antigravity/icon.svg +3 -0
  8. package/plugins/antigravity/plugin.js +484 -0
  9. package/plugins/antigravity/plugin.json +17 -0
  10. package/plugins/antigravity/plugin.test.js +1356 -0
  11. package/plugins/claude/icon.svg +3 -0
  12. package/plugins/claude/plugin.js +565 -0
  13. package/plugins/claude/plugin.json +28 -0
  14. package/plugins/claude/plugin.test.js +1012 -0
  15. package/plugins/codex/icon.svg +3 -0
  16. package/plugins/codex/plugin.js +673 -0
  17. package/plugins/codex/plugin.json +30 -0
  18. package/plugins/codex/plugin.test.js +1071 -0
  19. package/plugins/copilot/icon.svg +3 -0
  20. package/plugins/copilot/plugin.js +264 -0
  21. package/plugins/copilot/plugin.json +20 -0
  22. package/plugins/copilot/plugin.test.js +529 -0
  23. package/plugins/cursor/icon.svg +3 -0
  24. package/plugins/cursor/plugin.js +526 -0
  25. package/plugins/cursor/plugin.json +24 -0
  26. package/plugins/cursor/plugin.test.js +1168 -0
  27. package/plugins/factory/icon.svg +1 -0
  28. package/plugins/factory/plugin.js +407 -0
  29. package/plugins/factory/plugin.json +19 -0
  30. package/plugins/factory/plugin.test.js +833 -0
  31. package/plugins/gemini/icon.svg +4 -0
  32. package/plugins/gemini/plugin.js +413 -0
  33. package/plugins/gemini/plugin.json +20 -0
  34. package/plugins/gemini/plugin.test.js +735 -0
  35. package/plugins/jetbrains-ai-assistant/icon.svg +3 -0
  36. package/plugins/jetbrains-ai-assistant/plugin.js +357 -0
  37. package/plugins/jetbrains-ai-assistant/plugin.json +17 -0
  38. package/plugins/jetbrains-ai-assistant/plugin.test.js +338 -0
  39. package/plugins/kimi/icon.svg +3 -0
  40. package/plugins/kimi/plugin.js +358 -0
  41. package/plugins/kimi/plugin.json +19 -0
  42. package/plugins/kimi/plugin.test.js +619 -0
  43. package/plugins/minimax/icon.svg +4 -0
  44. package/plugins/minimax/plugin.js +388 -0
  45. package/plugins/minimax/plugin.json +17 -0
  46. package/plugins/minimax/plugin.test.js +943 -0
  47. package/plugins/perplexity/icon.svg +1 -0
  48. package/plugins/perplexity/plugin.js +378 -0
  49. package/plugins/perplexity/plugin.json +15 -0
  50. package/plugins/perplexity/plugin.test.js +602 -0
  51. package/plugins/windsurf/icon.svg +3 -0
  52. package/plugins/windsurf/plugin.js +218 -0
  53. package/plugins/windsurf/plugin.json +16 -0
  54. package/plugins/windsurf/plugin.test.js +455 -0
  55. package/plugins/zai/icon.svg +5 -0
  56. package/plugins/zai/plugin.js +156 -0
  57. package/plugins/zai/plugin.json +18 -0
  58. package/plugins/zai/plugin.test.js +396 -0
@@ -0,0 +1,19 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "kimi",
4
+ "name": "Kimi",
5
+ "version": "0.0.1",
6
+ "entry": "plugin.js",
7
+ "icon": "icon.svg",
8
+ "brandColor": "#000000",
9
+ "cli": {
10
+ "category": "cli",
11
+ "binaryName": "kimi",
12
+ "installCmd": null,
13
+ "loginCmd": "kimi login"
14
+ },
15
+ "lines": [
16
+ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
17
+ { "type": "progress", "label": "Weekly", "scope": "overview", "primaryOrder": 2 }
18
+ ]
19
+ }
@@ -0,0 +1,619 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { makeCtx } from "../test-helpers.js"
3
+
4
+ const CRED_PATH = "~/.kimi/credentials/kimi-code.json"
5
+
6
+ const loadPlugin = async () => {
7
+ await import("./plugin.js")
8
+ return globalThis.__openusage_plugin
9
+ }
10
+
11
+ describe("kimi plugin", () => {
12
+ beforeEach(() => {
13
+ delete globalThis.__openusage_plugin
14
+ vi.resetModules()
15
+ })
16
+
17
+ it("throws when credentials are missing", async () => {
18
+ const ctx = makeCtx()
19
+ const plugin = await loadPlugin()
20
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
21
+ })
22
+
23
+ it("refreshes token and renders session + weekly usage", async () => {
24
+ const ctx = makeCtx()
25
+ ctx.host.fs.writeText(
26
+ CRED_PATH,
27
+ JSON.stringify({
28
+ access_token: "old-token",
29
+ refresh_token: "refresh-token",
30
+ expires_at: 1,
31
+ })
32
+ )
33
+
34
+ ctx.host.http.request.mockImplementation((opts) => {
35
+ const url = String(opts.url)
36
+ if (url.includes("/api/oauth/token")) {
37
+ return {
38
+ status: 200,
39
+ bodyText: JSON.stringify({
40
+ access_token: "new-token",
41
+ refresh_token: "new-refresh",
42
+ expires_in: 3600,
43
+ scope: "kimi-code",
44
+ token_type: "Bearer",
45
+ }),
46
+ }
47
+ }
48
+
49
+ return {
50
+ status: 200,
51
+ bodyText: JSON.stringify({
52
+ usage: {
53
+ limit: "100",
54
+ remaining: "74",
55
+ resetTime: "2099-02-11T17:32:50.757941Z",
56
+ },
57
+ limits: [
58
+ {
59
+ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" },
60
+ detail: {
61
+ limit: "100",
62
+ remaining: "85",
63
+ resetTime: "2099-02-07T12:32:50.757941Z",
64
+ },
65
+ },
66
+ ],
67
+ user: {
68
+ membership: {
69
+ level: "LEVEL_INTERMEDIATE",
70
+ },
71
+ },
72
+ }),
73
+ }
74
+ })
75
+
76
+ const plugin = await loadPlugin()
77
+ const result = plugin.probe(ctx)
78
+
79
+ expect(result.plan).toBe("Intermediate")
80
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
81
+ expect(result.lines.find((line) => line.label === "Weekly")).toBeTruthy()
82
+
83
+ const persisted = JSON.parse(ctx.host.fs.readText(CRED_PATH))
84
+ expect(persisted.access_token).toBe("new-token")
85
+ expect(persisted.refresh_token).toBe("new-refresh")
86
+ })
87
+
88
+ it("retries usage once on 401 by refreshing token", async () => {
89
+ const ctx = makeCtx()
90
+ const nowSec = Math.floor(Date.now() / 1000)
91
+ ctx.host.fs.writeText(
92
+ CRED_PATH,
93
+ JSON.stringify({
94
+ access_token: "token",
95
+ refresh_token: "refresh-token",
96
+ expires_at: nowSec + 3600,
97
+ })
98
+ )
99
+
100
+ let usageCalls = 0
101
+ ctx.host.http.request.mockImplementation((opts) => {
102
+ const url = String(opts.url)
103
+ if (url.includes("/usages")) {
104
+ usageCalls += 1
105
+ if (usageCalls === 1) {
106
+ return { status: 401, bodyText: "" }
107
+ }
108
+ return {
109
+ status: 200,
110
+ bodyText: JSON.stringify({
111
+ usage: { limit: "100", remaining: "100", resetTime: "2099-02-11T00:00:00Z" },
112
+ limits: [
113
+ {
114
+ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" },
115
+ detail: { limit: "100", remaining: "100", resetTime: "2099-02-07T00:00:00Z" },
116
+ },
117
+ ],
118
+ }),
119
+ }
120
+ }
121
+
122
+ return {
123
+ status: 200,
124
+ bodyText: JSON.stringify({
125
+ access_token: "token-2",
126
+ refresh_token: "refresh-2",
127
+ expires_in: 3600,
128
+ }),
129
+ }
130
+ })
131
+
132
+ const plugin = await loadPlugin()
133
+ const result = plugin.probe(ctx)
134
+
135
+ expect(usageCalls).toBe(2)
136
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
137
+ })
138
+
139
+ it("throws session expired when refresh is unauthorized", async () => {
140
+ const ctx = makeCtx()
141
+ ctx.host.fs.writeText(
142
+ CRED_PATH,
143
+ JSON.stringify({
144
+ access_token: "token",
145
+ refresh_token: "refresh-token",
146
+ expires_at: 1,
147
+ })
148
+ )
149
+
150
+ ctx.host.http.request.mockImplementation((opts) => {
151
+ const url = String(opts.url)
152
+ if (url.includes("/api/oauth/token")) {
153
+ return { status: 401, bodyText: JSON.stringify({ error: "unauthorized" }) }
154
+ }
155
+ return { status: 500, bodyText: "" }
156
+ })
157
+
158
+ const plugin = await loadPlugin()
159
+ expect(() => plugin.probe(ctx)).toThrow("Session expired")
160
+ })
161
+
162
+ it("throws on invalid usage payload", async () => {
163
+ const ctx = makeCtx()
164
+ const nowSec = Math.floor(Date.now() / 1000)
165
+ ctx.host.fs.writeText(
166
+ CRED_PATH,
167
+ JSON.stringify({
168
+ access_token: "token",
169
+ refresh_token: "refresh-token",
170
+ expires_at: nowSec + 3600,
171
+ })
172
+ )
173
+
174
+ ctx.host.http.request.mockReturnValue({
175
+ status: 200,
176
+ bodyText: "not-json",
177
+ })
178
+
179
+ const plugin = await loadPlugin()
180
+ expect(() => plugin.probe(ctx)).toThrow("Usage response invalid")
181
+ })
182
+
183
+ it("treats malformed credentials file as not logged in", async () => {
184
+ const ctx = makeCtx()
185
+ ctx.host.fs.writeText(CRED_PATH, "{")
186
+
187
+ const plugin = await loadPlugin()
188
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
189
+ })
190
+
191
+ it("treats credentials without access and refresh tokens as not logged in", async () => {
192
+ const ctx = makeCtx()
193
+ ctx.host.fs.writeText(CRED_PATH, JSON.stringify({ access_token: "", refresh_token: "" }))
194
+
195
+ const plugin = await loadPlugin()
196
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
197
+ })
198
+
199
+ it("keeps existing access token when refresh returns non-2xx", async () => {
200
+ const ctx = makeCtx()
201
+ ctx.host.fs.writeText(
202
+ CRED_PATH,
203
+ JSON.stringify({
204
+ access_token: "old-token",
205
+ refresh_token: "refresh-token",
206
+ expires_at: 1,
207
+ })
208
+ )
209
+
210
+ ctx.host.http.request.mockImplementation((opts) => {
211
+ const url = String(opts.url)
212
+ if (url.includes("/api/oauth/token")) {
213
+ return { status: 500, bodyText: JSON.stringify({ error: "server_error" }) }
214
+ }
215
+ if (url.includes("/usages")) {
216
+ return {
217
+ status: 200,
218
+ bodyText: JSON.stringify({
219
+ usage: { limit: "100", remaining: "95", resetTime: "2099-02-11T00:00:00Z" },
220
+ limits: [
221
+ {
222
+ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" },
223
+ detail: { limit: "100", remaining: "90", resetTime: "2099-02-07T00:00:00Z" },
224
+ },
225
+ ],
226
+ }),
227
+ }
228
+ }
229
+ return { status: 404, bodyText: "" }
230
+ })
231
+
232
+ const plugin = await loadPlugin()
233
+ const result = plugin.probe(ctx)
234
+
235
+ const usageCall = ctx.host.http.request.mock.calls.find((call) =>
236
+ String(call[0]?.url).includes("/usages")
237
+ )
238
+ expect(usageCall?.[0]?.headers?.Authorization).toBe("Bearer old-token")
239
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
240
+ })
241
+
242
+ it("throws token expired when usage remains unauthorized", async () => {
243
+ const ctx = makeCtx()
244
+ const nowSec = Math.floor(Date.now() / 1000)
245
+ ctx.host.fs.writeText(
246
+ CRED_PATH,
247
+ JSON.stringify({
248
+ access_token: "token",
249
+ expires_at: nowSec + 3600,
250
+ })
251
+ )
252
+ ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" })
253
+
254
+ const plugin = await loadPlugin()
255
+ expect(() => plugin.probe(ctx)).toThrow("Token expired")
256
+ })
257
+
258
+ it("throws usage failed after refresh when second request throws", async () => {
259
+ const ctx = makeCtx()
260
+ const nowSec = Math.floor(Date.now() / 1000)
261
+ ctx.host.fs.writeText(
262
+ CRED_PATH,
263
+ JSON.stringify({
264
+ access_token: "token",
265
+ refresh_token: "refresh-token",
266
+ expires_at: nowSec + 3600,
267
+ })
268
+ )
269
+
270
+ let usageCalls = 0
271
+ ctx.host.http.request.mockImplementation((opts) => {
272
+ const url = String(opts.url)
273
+ if (url.includes("/api/oauth/token")) {
274
+ return {
275
+ status: 200,
276
+ bodyText: JSON.stringify({ access_token: "token-2", refresh_token: "refresh-2", expires_in: 3600 }),
277
+ }
278
+ }
279
+ if (url.includes("/usages")) {
280
+ usageCalls += 1
281
+ if (usageCalls === 1) return { status: 401, bodyText: "" }
282
+ throw new Error("network down")
283
+ }
284
+ return { status: 404, bodyText: "" }
285
+ })
286
+
287
+ const plugin = await loadPlugin()
288
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed after refresh")
289
+ })
290
+
291
+ it("throws usage failed when first request throws", async () => {
292
+ const ctx = makeCtx()
293
+ const nowSec = Math.floor(Date.now() / 1000)
294
+ ctx.host.fs.writeText(
295
+ CRED_PATH,
296
+ JSON.stringify({
297
+ access_token: "token",
298
+ expires_at: nowSec + 3600,
299
+ })
300
+ )
301
+ ctx.host.http.request.mockImplementation(() => {
302
+ throw new Error("offline")
303
+ })
304
+
305
+ const plugin = await loadPlugin()
306
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.")
307
+ })
308
+
309
+ it("throws not logged in when refresh response is missing access token", async () => {
310
+ const ctx = makeCtx()
311
+ ctx.host.fs.writeText(
312
+ CRED_PATH,
313
+ JSON.stringify({
314
+ refresh_token: "refresh-token",
315
+ expires_at: 1,
316
+ })
317
+ )
318
+ ctx.host.http.request.mockImplementation((opts) => {
319
+ const url = String(opts.url)
320
+ if (url.includes("/api/oauth/token")) {
321
+ return { status: 200, bodyText: JSON.stringify({ scope: "kimi-code" }) }
322
+ }
323
+ return { status: 404, bodyText: "" }
324
+ })
325
+
326
+ const plugin = await loadPlugin()
327
+ expect(() => plugin.probe(ctx)).toThrow("Not logged in")
328
+ })
329
+
330
+ it("returns badge when no valid quota is available", async () => {
331
+ const ctx = makeCtx()
332
+ const nowSec = Math.floor(Date.now() / 1000)
333
+ ctx.host.fs.writeText(
334
+ CRED_PATH,
335
+ JSON.stringify({
336
+ access_token: "token",
337
+ refresh_token: "refresh-token",
338
+ expires_at: nowSec + 3600,
339
+ })
340
+ )
341
+ ctx.host.http.request.mockReturnValue({
342
+ status: 200,
343
+ bodyText: JSON.stringify({
344
+ usage: { limit: "0", remaining: "0", resetTime: "2099-02-11T00:00:00Z" },
345
+ limits: [{ window: { duration: -5, timeUnit: "TIME_UNIT_DAY" }, detail: { limit: "0", used: "0" } }],
346
+ user: { membership: { level: "LEVEL_FREE" } },
347
+ }),
348
+ })
349
+
350
+ const plugin = await loadPlugin()
351
+ const result = plugin.probe(ctx)
352
+
353
+ expect(result.plan).toBe("Free")
354
+ expect(result.lines).toEqual([{ type: "badge", label: "Status", text: "No usage data", color: "#a3a3a3" }])
355
+ })
356
+
357
+ it("omits weekly line when weekly and session quotas are identical", async () => {
358
+ const ctx = makeCtx()
359
+ const nowSec = Math.floor(Date.now() / 1000)
360
+ ctx.host.fs.writeText(
361
+ CRED_PATH,
362
+ JSON.stringify({
363
+ access_token: "token",
364
+ refresh_token: "refresh-token",
365
+ expires_at: nowSec + 3600,
366
+ })
367
+ )
368
+ ctx.host.http.request.mockReturnValue({
369
+ status: 200,
370
+ bodyText: JSON.stringify({
371
+ usage: { limit: "100", remaining: "90", resetTime: "2099-02-11T00:00:00Z" },
372
+ limits: [
373
+ {
374
+ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" },
375
+ detail: { limit: "100", remaining: "90", resetTime: "2099-02-11T00:00:00Z" },
376
+ },
377
+ ],
378
+ }),
379
+ })
380
+
381
+ const plugin = await loadPlugin()
382
+ const result = plugin.probe(ctx)
383
+
384
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
385
+ expect(result.lines.find((line) => line.label === "Weekly")).toBeFalsy()
386
+ })
387
+
388
+ it("selects largest remaining period as weekly when usage block is absent", async () => {
389
+ const ctx = makeCtx()
390
+ const nowSec = Math.floor(Date.now() / 1000)
391
+ ctx.host.fs.writeText(
392
+ CRED_PATH,
393
+ JSON.stringify({
394
+ access_token: "token",
395
+ refresh_token: "refresh-token",
396
+ expires_at: nowSec + 3600,
397
+ })
398
+ )
399
+ ctx.host.http.request.mockReturnValue({
400
+ status: 200,
401
+ bodyText: JSON.stringify({
402
+ limits: [
403
+ {
404
+ window: { duration: 5, timeUnit: "TIME_UNIT_MINUTE" },
405
+ detail: { limit: "100", remaining: "80", resetTime: "2099-02-07T00:00:00Z" },
406
+ },
407
+ {
408
+ window: { duration: 1, timeUnit: "TIME_UNIT_DAY" },
409
+ detail: { limit: "100", remaining: "40", resetTime: "2099-02-08T00:00:00Z" },
410
+ },
411
+ {
412
+ window: { duration: 1, timeUnit: "TIME_UNIT_HOUR" },
413
+ detail: { limit: "100", remaining: "70", resetTime: "2099-02-07T01:00:00Z" },
414
+ },
415
+ ],
416
+ }),
417
+ })
418
+
419
+ const plugin = await loadPlugin()
420
+ const result = plugin.probe(ctx)
421
+
422
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
423
+ const weekly = result.lines.find((line) => line.label === "Weekly")
424
+ expect(weekly).toBeTruthy()
425
+ expect(weekly.periodDurationMs).toBe(24 * 60 * 60 * 1000)
426
+ })
427
+
428
+ it("throws HTTP status error when usage endpoint returns non-2xx", async () => {
429
+ const ctx = makeCtx()
430
+ const nowSec = Math.floor(Date.now() / 1000)
431
+ ctx.host.fs.writeText(
432
+ CRED_PATH,
433
+ JSON.stringify({
434
+ access_token: "token",
435
+ expires_at: nowSec + 3600,
436
+ })
437
+ )
438
+ ctx.host.http.request.mockReturnValue({ status: 500, bodyText: "" })
439
+
440
+ const plugin = await loadPlugin()
441
+ expect(() => plugin.probe(ctx)).toThrow("Usage request failed (HTTP 500)")
442
+ })
443
+
444
+ it("handles second and unknown limit units when picking session and weekly", async () => {
445
+ const ctx = makeCtx()
446
+ const nowSec = Math.floor(Date.now() / 1000)
447
+ ctx.host.fs.writeText(
448
+ CRED_PATH,
449
+ JSON.stringify({
450
+ access_token: "token",
451
+ expires_at: nowSec + 3600,
452
+ })
453
+ )
454
+ ctx.host.http.request.mockReturnValue({
455
+ status: 200,
456
+ bodyText: JSON.stringify({
457
+ limits: [
458
+ {
459
+ window: { duration: 1, timeUnit: "TIME_UNIT_YEAR" },
460
+ detail: { limit: "100", remaining: "90", resetTime: "2099-02-07T00:00:00Z" },
461
+ },
462
+ {
463
+ window: { duration: 30, timeUnit: "TIME_UNIT_SECOND" },
464
+ detail: { limit: "100", remaining: "80", resetTime: "2099-02-07T00:00:30Z" },
465
+ },
466
+ {
467
+ window: { duration: 2, timeUnit: "TIME_UNIT_UNKNOWN" },
468
+ detail: { limit: "100", remaining: "70", resetTime: "2099-02-07T00:02:00Z" },
469
+ },
470
+ ],
471
+ }),
472
+ })
473
+
474
+ const plugin = await loadPlugin()
475
+ const result = plugin.probe(ctx)
476
+
477
+ const session = result.lines.find((line) => line.label === "Session")
478
+ expect(session).toBeTruthy()
479
+ expect(session.periodDurationMs).toBe(30 * 1000)
480
+ expect(result.lines.find((line) => line.label === "Weekly")).toBeTruthy()
481
+ })
482
+
483
+ it("handles all-unknown limit windows without crashing", async () => {
484
+ const ctx = makeCtx()
485
+ const nowSec = Math.floor(Date.now() / 1000)
486
+ ctx.host.fs.writeText(
487
+ CRED_PATH,
488
+ JSON.stringify({
489
+ access_token: "token",
490
+ expires_at: nowSec + 3600,
491
+ })
492
+ )
493
+ ctx.host.http.request.mockReturnValue({
494
+ status: 200,
495
+ bodyText: JSON.stringify({
496
+ limits: [
497
+ {
498
+ window: { duration: 1, timeUnit: "TIME_UNIT_UNKNOWN_A" },
499
+ detail: { limit: "100", remaining: "95", resetTime: "2099-02-07T00:00:00Z" },
500
+ },
501
+ {
502
+ window: { duration: 2, timeUnit: "TIME_UNIT_UNKNOWN_B" },
503
+ detail: { limit: "100", remaining: "85", resetTime: "2099-02-08T00:00:00Z" },
504
+ },
505
+ ],
506
+ }),
507
+ })
508
+
509
+ const plugin = await loadPlugin()
510
+ const result = plugin.probe(ctx)
511
+
512
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
513
+ })
514
+
515
+ it("refreshes with minimal token payload and keeps existing optional fields", async () => {
516
+ const ctx = makeCtx()
517
+ ctx.host.fs.writeText(
518
+ CRED_PATH,
519
+ JSON.stringify({
520
+ access_token: "old-token",
521
+ refresh_token: "refresh-token",
522
+ expires_at: 1,
523
+ scope: "existing-scope",
524
+ token_type: "Bearer",
525
+ })
526
+ )
527
+ ctx.host.http.request.mockImplementation((opts) => {
528
+ const url = String(opts.url)
529
+ if (url.includes("/api/oauth/token")) {
530
+ return { status: 200, bodyText: JSON.stringify({ access_token: "new-token" }) }
531
+ }
532
+ return {
533
+ status: 200,
534
+ bodyText: JSON.stringify({
535
+ limits: [
536
+ {
537
+ window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" },
538
+ detail: { limit: "100", used: "20", reset_time: "2099-02-07T00:00:00Z" },
539
+ },
540
+ ],
541
+ }),
542
+ }
543
+ })
544
+
545
+ const plugin = await loadPlugin()
546
+ const result = plugin.probe(ctx)
547
+ expect(result.lines.find((line) => line.label === "Session")).toBeTruthy()
548
+ const persisted = JSON.parse(ctx.host.fs.readText(CRED_PATH))
549
+ expect(persisted.access_token).toBe("new-token")
550
+ expect(persisted.scope).toBe("existing-scope")
551
+ expect(persisted.token_type).toBe("Bearer")
552
+ })
553
+
554
+ it("supports root-level limits with snake_case window keys and remaining-based usage", async () => {
555
+ const ctx = makeCtx()
556
+ const nowSec = Math.floor(Date.now() / 1000)
557
+ ctx.host.fs.writeText(
558
+ CRED_PATH,
559
+ JSON.stringify({
560
+ access_token: "token",
561
+ expires_at: nowSec + 3600,
562
+ })
563
+ )
564
+ ctx.host.http.request.mockReturnValue({
565
+ status: 200,
566
+ bodyText: JSON.stringify({
567
+ limits: [
568
+ {
569
+ window: { duration: 1, time_unit: "TIME_UNIT_DAY" },
570
+ limit: "200",
571
+ remaining: "150",
572
+ reset_at: "2099-02-08T00:00:00Z",
573
+ },
574
+ {
575
+ // Invalid limit should be skipped.
576
+ window: { duration: 0, time_unit: "TIME_UNIT_DAY" },
577
+ limit: "0",
578
+ remaining: "0",
579
+ },
580
+ ],
581
+ user: { membership: { level: "LEVEL_" } },
582
+ }),
583
+ })
584
+
585
+ const plugin = await loadPlugin()
586
+ const result = plugin.probe(ctx)
587
+ const session = result.lines.find((line) => line.label === "Session")
588
+ expect(session).toBeTruthy()
589
+ expect(session.used).toBe(25) // (200-150)/200
590
+ expect(session.periodDurationMs).toBe(24 * 60 * 60 * 1000)
591
+ expect(result.plan).toBeNull()
592
+ })
593
+
594
+ it("adds weekly line from usage block when session candidate is unavailable", async () => {
595
+ const ctx = makeCtx()
596
+ const nowSec = Math.floor(Date.now() / 1000)
597
+ ctx.host.fs.writeText(
598
+ CRED_PATH,
599
+ JSON.stringify({
600
+ access_token: "token",
601
+ expires_at: nowSec + 3600,
602
+ })
603
+ )
604
+ ctx.host.http.request.mockReturnValue({
605
+ status: 200,
606
+ bodyText: JSON.stringify({
607
+ usage: { limit: "500", used: "125", resetAt: "2099-02-11T00:00:00Z" },
608
+ limits: [],
609
+ }),
610
+ })
611
+
612
+ const plugin = await loadPlugin()
613
+ const result = plugin.probe(ctx)
614
+ expect(result.lines.find((line) => line.label === "Session")).toBeUndefined()
615
+ const weekly = result.lines.find((line) => line.label === "Weekly")
616
+ expect(weekly).toBeTruthy()
617
+ expect(weekly.used).toBe(25)
618
+ })
619
+ })
@@ -0,0 +1,4 @@
1
+ <svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg">
2
+ <title>Minimax</title>
3
+ <path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="currentColor" fill-rule="nonzero" />
4
+ </svg>