gusage 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/index.js +378 -0
  4. package/package.json +36 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdellah Hariti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # gusage
2
+
3
+ A standalone, sub-second CLI to export and monitor Gemini CLI quota and usage statistics.
4
+
5
+ <p align="center">
6
+ <img src="assets/demo.png" width="500" />
7
+ </p>
8
+
9
+ This tool reverse-engineers the internal API handshakes used by the main Gemini CLI to fetch usage statistics.
10
+
11
+ ## Features
12
+
13
+ - **Live Monitoring:** Real-time quota updates using the `--watch` flag.
14
+ - **Machine Readable:** Supports JSON output for easy integration with other tools.
15
+ - **Fast:** Returns results in sub-second time.
16
+ - **Smart Sorting:** Automatically sorts models to highlight the ones you care about.
17
+ - **Beautiful TUI**
18
+
19
+ ## Installation
20
+
21
+ You can run it directly without installation using `npx` or `bunx`:
22
+
23
+ ```bash
24
+ bunx gusage
25
+ # or
26
+ npx gusage
27
+ ```
28
+
29
+ Or install it globally:
30
+
31
+ ```bash
32
+ bun add -g gusage
33
+ # or
34
+ npm install -g gusage
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ # Display quota in a beautiful table (default)
41
+ gusage
42
+
43
+ # Monitor quota live every 10 seconds
44
+ gusage --watch
45
+
46
+ # Monitor quota every 1 minute and 20 seconds
47
+ gusage --watch 1m20s
48
+
49
+ # Output raw JSON for scripting
50
+ gusage -o json
51
+
52
+ # Disable colors
53
+ gusage --no-color
54
+ ```
55
+
56
+ ## Options
57
+
58
+ - `-h, --help`: Show help message.
59
+ - `-w, --watch [interval]`: Update live every interval (default: 10s). Supports units like `20s`, `5m`, `1m20s`.
60
+ - `-o, --output-format <fmt>`: Set output format to `table` (default) or `json`.
61
+ - `--no-color`: Disable color output (also respects `NO_COLOR` env var).
62
+
63
+ ## Requirements
64
+
65
+ - **Authentication:** You must have already authenticated via the official Gemini CLI (`gemini login`).
66
+
67
+ ## License
68
+
69
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // index.ts
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import os from "os";
8
+ import crypto from "crypto";
9
+ import { parseArgs } from "util";
10
+ var OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
11
+ var OAUTH_CLIENT_SECRET = "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";
12
+ var CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
13
+ var CODE_ASSIST_API_VERSION = "v1internal";
14
+ var GEMINI_DIR = ".gemini";
15
+ var MAIN_ACCOUNT_KEY = "main-account";
16
+ var ENCRYPTED_FILE_NAME = "mcp-oauth-tokens-v2.json";
17
+ var LEGACY_OAUTH_FILE = "oauth_creds.json";
18
+ var VALID_GEMINI_MODELS = new Set([
19
+ "gemini-3-pro-preview",
20
+ "gemini-3-flash-preview",
21
+ "gemini-2.5-pro",
22
+ "gemini-2.5-flash",
23
+ "gemini-2.5-flash-lite"
24
+ ]);
25
+ var SEONDARY_MODELS = ["gemini-2.5-flash", "gemini-2.5-flash-lite"];
26
+ function formatRelativeTime(dateString) {
27
+ const now = new Date;
28
+ const resetTime = new Date(dateString);
29
+ const diffMs = resetTime.getTime() - now.getTime();
30
+ if (diffMs <= 0)
31
+ return "Resetting...";
32
+ const diffMins = Math.floor(diffMs / 60000);
33
+ const h = Math.floor(diffMins / 60);
34
+ const m = diffMins % 60;
35
+ if (h > 0)
36
+ return `${h}h ${m}m`;
37
+ return `${m}m`;
38
+ }
39
+ function parseVersion(modelId) {
40
+ const match = modelId.match(/gemini-(\d+)(?:\.(\d+))?-(.*)/);
41
+ if (match) {
42
+ return {
43
+ major: parseInt(match[1], 10),
44
+ minor: match[2] ? parseInt(match[2], 10) : 0,
45
+ suffix: match[3] || ""
46
+ };
47
+ }
48
+ return { major: 0, minor: 0, suffix: modelId };
49
+ }
50
+ function renderProgressBar(fraction, width, useColor, isMuted = false) {
51
+ const BLOCKS = ["", "\u258F", "\u258E", "\u258D", "\u258C", "\u258B", "\u258A", "\u2589"];
52
+ const units = Math.round(Math.max(0, Math.min(1, fraction)) * width * 8);
53
+ const full = Math.floor(units / 8);
54
+ const rem = units % 8;
55
+ if (!useColor) {
56
+ let bar2 = "\u2588".repeat(full);
57
+ if (full < width) {
58
+ bar2 += BLOCKS[rem] || "";
59
+ }
60
+ const remaining = width - visualLength(bar2);
61
+ if (remaining > 0) {
62
+ bar2 += "\u2591".repeat(remaining);
63
+ }
64
+ return bar2;
65
+ }
66
+ const fillCol = fraction < 0.2 ? 160 : isMuted ? 244 : 15;
67
+ const trackCol = 237;
68
+ const fg = (n) => `\x1B[38;5;${n}m`;
69
+ const bg = (n) => `\x1B[48;5;${n}m`;
70
+ const reset = `\x1B[0m`;
71
+ let bar = bg(trackCol);
72
+ for (let i = 0;i < width; i++) {
73
+ if (i < full) {
74
+ bar += fg(fillCol) + "\u2588";
75
+ } else if (i === full && rem > 0) {
76
+ bar += fg(fillCol) + BLOCKS[rem];
77
+ } else {
78
+ bar += fg(trackCol) + "\u2588";
79
+ }
80
+ }
81
+ return bar + reset;
82
+ }
83
+ function visualLength(str) {
84
+ return str.replace(/\x1b\[[0-9;]*m/g, "").length;
85
+ }
86
+ function padVisual(str, width, side = "right") {
87
+ const len = visualLength(str);
88
+ const pad = " ".repeat(Math.max(0, width - len));
89
+ return side === "right" ? str + pad : pad + str;
90
+ }
91
+ async function main() {
92
+ const args = Bun.argv.slice(2);
93
+ const watchIdx = args.findIndex((a) => a === "--watch" || a === "-w");
94
+ if (watchIdx !== -1 && (watchIdx === args.length - 1 || args[watchIdx + 1].startsWith("-"))) {
95
+ args.splice(watchIdx + 1, 0, "10s");
96
+ }
97
+ const { values } = parseArgs({
98
+ args,
99
+ options: {
100
+ help: { type: "boolean", short: "h" },
101
+ "output-format": { type: "string", short: "o", default: "table" },
102
+ "no-color": { type: "boolean" },
103
+ watch: { type: "string", short: "w" }
104
+ },
105
+ strict: true
106
+ });
107
+ if (values.help) {
108
+ console.log(`
109
+ Usage: gusage [options]
110
+
111
+ Options:
112
+ -h, --help Show this help message
113
+ -w, --watch [interval] Update live every interval (default: 10s).
114
+ Supports combined units: 20s, 5m, 1m20s.
115
+ -o, --output-format <fmt> Output format: table (default), json
116
+ --no-color Disable color output
117
+ `);
118
+ return;
119
+ }
120
+ const outputFormat = values["output-format"];
121
+ if (outputFormat !== "json" && outputFormat !== "table") {
122
+ console.error(`Error: Unsupported output format "${outputFormat}". Use "table" or "json".`);
123
+ process.exit(1);
124
+ }
125
+ const isWatching = values.watch !== undefined;
126
+ let intervalMs = 1e4;
127
+ let intervalStr = "10s";
128
+ if (values.watch) {
129
+ let totalMs = 0;
130
+ const matches = values.watch.matchAll(/(\d+)(h|m|s)?/g);
131
+ let found = false;
132
+ for (const match of matches) {
133
+ found = true;
134
+ const val = parseInt(match[1], 10);
135
+ const unit = match[2] || "s";
136
+ if (unit === "s")
137
+ totalMs += val * 1000;
138
+ else if (unit === "m")
139
+ totalMs += val * 60000;
140
+ else if (unit === "h")
141
+ totalMs += val * 3600000;
142
+ }
143
+ if (found) {
144
+ intervalMs = totalMs;
145
+ intervalStr = values.watch;
146
+ }
147
+ }
148
+ const useColor = !values["no-color"] && !process.env.NO_COLOR && process.stdout.isTTY;
149
+ const colors = {
150
+ reset: useColor ? "\x1B[0m" : "",
151
+ dim: useColor ? "\x1B[2m" : "",
152
+ cyan: useColor ? "\x1B[36m" : "",
153
+ green: useColor ? "\x1B[32m" : "",
154
+ yellow: useColor ? "\x1B[33m" : "",
155
+ red: useColor ? "\x1B[31m" : "",
156
+ clear: useColor ? "\x1B[2J\x1B[H" : "",
157
+ hideCursor: useColor ? "\x1B[?25l" : "",
158
+ showCursor: useColor ? "\x1B[?25h" : ""
159
+ };
160
+ const cleanup = () => {
161
+ if (isWatching && process.stdin.isTTY) {
162
+ process.stdin.setRawMode(false);
163
+ process.stdin.pause();
164
+ }
165
+ process.stdout.write(colors.showCursor);
166
+ };
167
+ const CTRL_C = "\x03";
168
+ const CTRL_D = "\x04";
169
+ if (isWatching && process.stdin.isTTY) {
170
+ process.stdin.setRawMode(true);
171
+ process.stdin.resume();
172
+ process.stdin.on("data", (data) => {
173
+ const key = data.toString();
174
+ if (key === "q" || key === CTRL_C || key === CTRL_D) {
175
+ cleanup();
176
+ process.exit(0);
177
+ }
178
+ });
179
+ process.stdout.write(colors.hideCursor);
180
+ }
181
+ process.on("SIGINT", () => {
182
+ cleanup();
183
+ process.exit(0);
184
+ });
185
+ process.on("SIGTERM", () => {
186
+ cleanup();
187
+ process.exit(0);
188
+ });
189
+ const run = async () => {
190
+ const creds = loadLocalCredentials();
191
+ if (!creds) {
192
+ console.error('Error: No credentials found. Please run "gemini login" first.');
193
+ process.exit(1);
194
+ }
195
+ let token = creds.access_token;
196
+ const isExpired = creds.expiry_date && Date.now() > creds.expiry_date - 60000;
197
+ if (isExpired && creds.refresh_token) {
198
+ try {
199
+ const refreshed = await refreshAccessToken(creds.refresh_token);
200
+ token = refreshed.access_token;
201
+ } catch (e) {
202
+ const message = e instanceof Error ? e.message : String(e);
203
+ console.error("Error: Failed to refresh access token.", message);
204
+ }
205
+ }
206
+ const baseUrl = `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}`;
207
+ const authHeader = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
208
+ const loadResponse = await fetch(`${baseUrl}:loadCodeAssist`, {
209
+ method: "POST",
210
+ headers: authHeader,
211
+ body: JSON.stringify({
212
+ metadata: { ideType: "GEMINI_CLI", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }
213
+ })
214
+ });
215
+ if (!loadResponse.ok) {
216
+ console.error(`Error: loadCodeAssist failed (${loadResponse.status})`);
217
+ process.exit(1);
218
+ }
219
+ const loadData = await loadResponse.json();
220
+ const projectId = loadData.cloudaicompanionProject || process.env["GOOGLE_CLOUD_PROJECT"];
221
+ if (!projectId) {
222
+ console.error("Error: Could not determine Project ID.");
223
+ process.exit(1);
224
+ }
225
+ const quotaResponse = await fetch(`${baseUrl}:retrieveUserQuota`, {
226
+ method: "POST",
227
+ headers: authHeader,
228
+ body: JSON.stringify({ project: projectId })
229
+ });
230
+ if (!quotaResponse.ok) {
231
+ console.error(`Error: retrieveUserQuota failed (${quotaResponse.status})`);
232
+ process.exit(1);
233
+ }
234
+ const quotaData = await quotaResponse.json();
235
+ if (quotaData.buckets) {
236
+ quotaData.buckets = quotaData.buckets.filter((b) => b.modelId && VALID_GEMINI_MODELS.has(b.modelId)).sort((a, b) => {
237
+ const vA = parseVersion(a.modelId);
238
+ const vB = parseVersion(b.modelId);
239
+ if (vA.major !== vB.major)
240
+ return vB.major - vA.major;
241
+ if (vA.minor !== vB.minor)
242
+ return vB.minor - vA.minor;
243
+ return vB.suffix.localeCompare(vA.suffix);
244
+ });
245
+ }
246
+ if (isWatching)
247
+ process.stdout.write(colors.clear);
248
+ if (outputFormat === "json") {
249
+ console.log(JSON.stringify(quotaData.buckets || [], null, 2));
250
+ } else {
251
+ if (!quotaData.buckets || quotaData.buckets.length === 0) {
252
+ console.log("No quota data available.");
253
+ return;
254
+ }
255
+ const headers = ["Gemini Model", "Remaining %", "Reset Time"];
256
+ const BAR_WIDTH = 20;
257
+ const terminalWidth = process.stdout.columns || 80;
258
+ const showBar = terminalWidth > 60;
259
+ const tableData = quotaData.buckets.map((b) => {
260
+ const fraction = b.remainingFraction ?? 0;
261
+ const isMuted = SEONDARY_MODELS.includes(b.modelId);
262
+ const model = b.modelId.replace("gemini-", "");
263
+ const bar = showBar ? renderProgressBar(fraction, BAR_WIDTH, useColor, isMuted) : "";
264
+ const pct = `${Math.round(fraction * 100)}%`.padStart(4);
265
+ const reset = b.resetTime ? formatRelativeTime(b.resetTime) : "N/A";
266
+ return { model, bar, pct, reset, isMuted };
267
+ });
268
+ const modelWidth = Math.max(headers[0].length, ...tableData.map((d) => d.model.length));
269
+ const remainingWidth = Math.max(headers[1].length, showBar ? BAR_WIDTH + 1 + 4 : 4);
270
+ const resetWidth = Math.max(headers[2].length, ...tableData.map((d) => d.reset.length));
271
+ const h0 = headers[0].padEnd(modelWidth);
272
+ const h1 = headers[1].padEnd(remainingWidth);
273
+ const h2 = headers[2].padEnd(resetWidth);
274
+ const headerRow = `${h0} ${h1} ${h2}`;
275
+ console.log(headerRow);
276
+ console.log(`${colors.dim}${"\u2500".repeat(visualLength(headerRow))}${colors.reset}`);
277
+ tableData.forEach((d, idx) => {
278
+ const modelLabel = d.isMuted ? `${colors.dim}${d.model}${colors.reset}` : d.model;
279
+ const m = padVisual(modelLabel, modelWidth);
280
+ const r_content = showBar ? `${d.bar} ${colors.dim}${d.pct}${colors.reset}` : `${colors.dim}${d.pct}${colors.reset}`;
281
+ const r = padVisual(r_content, remainingWidth);
282
+ const resetLabel = d.isMuted ? `${colors.dim}${d.reset}${colors.reset}` : d.reset;
283
+ const t = padVisual(resetLabel, resetWidth);
284
+ console.log(`${m} ${r} ${t}`);
285
+ if (idx < tableData.length - 1) {
286
+ console.log("");
287
+ }
288
+ });
289
+ if (isWatching) {
290
+ console.log(`
291
+ ${colors.dim}Updating every ${intervalStr}, press q to quit${colors.reset}`);
292
+ }
293
+ }
294
+ };
295
+ await run();
296
+ if (isWatching) {
297
+ setInterval(run, intervalMs);
298
+ }
299
+ }
300
+ if (import.meta.main) {
301
+ main().catch((err) => {
302
+ const message = err instanceof Error ? err.message : String(err);
303
+ console.error("Fatal Error:", message);
304
+ process.exit(1);
305
+ });
306
+ }
307
+ function getGeminiDir() {
308
+ return path.join(os.homedir(), GEMINI_DIR);
309
+ }
310
+ function deriveEncryptionKey() {
311
+ const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
312
+ return crypto.scryptSync("gemini-cli-oauth", salt, 32);
313
+ }
314
+ function decrypt(encryptedData, key) {
315
+ const parts = encryptedData.split(":");
316
+ if (parts.length !== 3) {
317
+ throw new Error("Invalid encrypted data format");
318
+ }
319
+ const ivHex = parts[0];
320
+ const authTagHex = parts[1];
321
+ const encrypted = parts[2];
322
+ const iv = Buffer.from(ivHex, "hex");
323
+ const authTag = Buffer.from(authTagHex, "hex");
324
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
325
+ decipher.setAuthTag(authTag);
326
+ let decrypted = decipher.update(encrypted, "hex", "utf8");
327
+ decrypted += decipher.final("utf8");
328
+ return decrypted;
329
+ }
330
+ function loadLocalCredentials() {
331
+ const geminiDir = getGeminiDir();
332
+ const v2Path = path.join(geminiDir, ENCRYPTED_FILE_NAME);
333
+ if (fs.existsSync(v2Path)) {
334
+ try {
335
+ const data = fs.readFileSync(v2Path, "utf-8");
336
+ const key = deriveEncryptionKey();
337
+ const decrypted = decrypt(data, key);
338
+ const tokens = JSON.parse(decrypted);
339
+ const mainAccount = tokens[MAIN_ACCOUNT_KEY];
340
+ if (mainAccount && mainAccount.token) {
341
+ return {
342
+ access_token: mainAccount.token.accessToken,
343
+ refresh_token: mainAccount.token.refreshToken,
344
+ expiry_date: mainAccount.token.expiresAt,
345
+ token_type: mainAccount.token.tokenType
346
+ };
347
+ }
348
+ } catch (e) {}
349
+ }
350
+ const legacyPath = path.join(geminiDir, LEGACY_OAUTH_FILE);
351
+ if (fs.existsSync(legacyPath)) {
352
+ try {
353
+ return JSON.parse(fs.readFileSync(legacyPath, "utf-8"));
354
+ } catch (e) {}
355
+ }
356
+ return null;
357
+ }
358
+ async function refreshAccessToken(refreshToken) {
359
+ const url = "https://oauth2.googleapis.com/token";
360
+ const body = new URLSearchParams({
361
+ client_id: OAUTH_CLIENT_ID,
362
+ client_secret: OAUTH_CLIENT_SECRET,
363
+ refresh_token: refreshToken,
364
+ grant_type: "refresh_token"
365
+ });
366
+ const response = await fetch(url, {
367
+ method: "POST",
368
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
369
+ body: body.toString()
370
+ });
371
+ if (!response.ok) {
372
+ throw new Error(`Failed to refresh token: ${response.statusText}`);
373
+ }
374
+ return response.json();
375
+ }
376
+ export {
377
+ main as default
378
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "gusage",
3
+ "version": "1.0.0",
4
+ "description": "A standalone CLI to export Gemini CLI quota and usage statistics",
5
+ "module": "index.ts",
6
+ "type": "module",
7
+ "bin": {
8
+ "gusage": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/index.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "author": "Abdellah Hariti <haritiabdellah@gmail.com>",
16
+ "license": "MIT",
17
+ "private": false,
18
+ "scripts": {
19
+ "format": "prettier --write .",
20
+ "prepare": "husky",
21
+ "build": "bun build ./index.ts --outfile dist/index.js && chmod +x dist/index.js"
22
+ },
23
+ "lint-staged": {
24
+ "*": "prettier --write --ignore-unknown",
25
+ "*.ts": "bash -c 'bun x tsc --noEmit'"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "latest",
29
+ "husky": "^9.1.7",
30
+ "lint-staged": "^16.2.7",
31
+ "prettier": "^3.8.1"
32
+ },
33
+ "peerDependencies": {
34
+ "typescript": "^5"
35
+ }
36
+ }