gusage 1.1.0 → 1.1.2

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 (3) hide show
  1. package/README.md +9 -0
  2. package/dist/index.js +94 -12
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -11,6 +11,7 @@ This tool reverse-engineers the internal API handshakes used by the main Gemini
11
11
  ## Features
12
12
 
13
13
  - **Live Monitoring:** Real-time quota updates using the `--watch` flag.
14
+ - **Low-Quota Alerts:** Optional desktop notifications with `--notify` when usage drops below a threshold.
14
15
  - **Machine Readable:** Supports JSON output for easy integration with other tools.
15
16
  - **Fast:** Returns results in sub-second time.
16
17
  - **Smart Sorting:** Automatically sorts models to highlight the ones you care about.
@@ -46,6 +47,12 @@ gusage --watch
46
47
  # Monitor quota every 1 minute and 20 seconds
47
48
  gusage --watch 1m20s
48
49
 
50
+ # Monitor quota and notify when any model falls below 20% (default threshold)
51
+ gusage --watch --notify
52
+
53
+ # Monitor quota and notify when any model falls below 15%
54
+ gusage --watch 30s --notify 15
55
+
49
56
  # Output raw JSON for scripting
50
57
  gusage --json | jq .
51
58
 
@@ -57,12 +64,14 @@ gusage --json --watch 5s | jq .
57
64
 
58
65
  - `-h, --help`: Show help message.
59
66
  - `-w, --watch [interval]`: Update live every interval (default: 10s). Supports units like `20s`, `5m`, `1m20s`.
67
+ - `-n, --notify [threshold]`: Send a desktop notification when a model drops below the threshold percent (default: `20`). Requires `--watch`.
60
68
  - `-j, --json`: Output raw JSON instead of a table. Can be combined with `--watch` for streaming data.
61
69
  - `--no-color`: Disable color output (also respects `NO_COLOR` env var).
62
70
 
63
71
  ## Requirements
64
72
 
65
73
  - **Authentication:** You must have already authenticated via the official Gemini CLI (`gemini login`).
74
+ - **Notifications (Linux only):** `notify-send` must be installed when using `--notify`.
66
75
 
67
76
  ## License
68
77
 
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import path from "path";
7
7
  import os from "os";
8
8
  import crypto from "crypto";
9
9
  import { parseArgs } from "util";
10
+ import { execSync, spawn } from "child_process";
10
11
  var OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
11
12
  var OAUTH_CLIENT_SECRET = "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";
12
13
  var CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
@@ -36,6 +37,56 @@ function formatRelativeTime(dateString) {
36
37
  return `${h}h ${m}m`;
37
38
  return `${m}m`;
38
39
  }
40
+ function sendNotification(title, message) {
41
+ const spawnDetached = (command, args) => {
42
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
43
+ child.on("error", () => {});
44
+ child.unref();
45
+ };
46
+ if (process.platform === "darwin") {
47
+ const escapedMessage = message.replace(/"/g, "\\\"");
48
+ const escapedTitle = title.replace(/"/g, "\\\"");
49
+ const script = `display notification "${escapedMessage}" with title "${escapedTitle}"`;
50
+ spawnDetached("osascript", ["-e", script]);
51
+ } else if (process.platform === "linux") {
52
+ spawnDetached("notify-send", [title, message]);
53
+ } else if (process.platform === "win32") {
54
+ const command = `Add-Type -AssemblyName System.Windows.Forms; $g = [System.Windows.Forms.NotifyIcon]::new(); $g.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon("$PSHOME\\powershell.exe"); $g.Visible = $true; $g.ShowBalloonTip(5000, "${title.replace(/"/g, '`"')}", "${message.replace(/"/g, '`"')}", [System.Windows.Forms.ToolTipIcon]::Info);`;
55
+ spawnDetached("powershell", ["-Command", command]);
56
+ }
57
+ }
58
+ function checkNotificationSupport() {
59
+ if (process.platform === "linux") {
60
+ try {
61
+ execSync("which notify-send", { stdio: "ignore" });
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+ return true;
68
+ }
69
+ function parseNotifyThresholdArg(raw) {
70
+ if (raw === undefined)
71
+ return null;
72
+ const thresholdPct = Number(raw);
73
+ if (!Number.isFinite(thresholdPct) || thresholdPct < 0 || thresholdPct > 100) {
74
+ throw new Error("Error: --notify threshold must be a number between 0 and 100.");
75
+ }
76
+ return thresholdPct / 100;
77
+ }
78
+ function validateNotifyRuntime(notifyThreshold, isWatching, hasNotificationSupport) {
79
+ if (notifyThreshold === null)
80
+ return null;
81
+ if (!isWatching)
82
+ return "Error: --notify requires --watch.";
83
+ if (!hasNotificationSupport)
84
+ return "Error: --notify is not supported on this system (missing notify-send).";
85
+ return null;
86
+ }
87
+ function shouldSendThresholdNotification(prevFraction, currentFraction, threshold) {
88
+ return prevFraction !== undefined && prevFraction > threshold && currentFraction <= threshold;
89
+ }
39
90
  function parseVersion(modelId) {
40
91
  const match = modelId.match(/gemini-(\d+)(?:\.(\d+))?-(.*)/);
41
92
  if (match) {
@@ -94,13 +145,18 @@ async function main() {
94
145
  if (watchIdx !== -1 && (watchIdx === args.length - 1 || args[watchIdx + 1].startsWith("-"))) {
95
146
  args.splice(watchIdx + 1, 0, "10s");
96
147
  }
148
+ const notifyIdx = args.findIndex((a) => a === "--notify" || a === "-n");
149
+ if (notifyIdx !== -1 && (notifyIdx === args.length - 1 || args[notifyIdx + 1].startsWith("-"))) {
150
+ args.splice(notifyIdx + 1, 0, "20");
151
+ }
97
152
  const { values } = parseArgs({
98
153
  args,
99
154
  options: {
100
155
  help: { type: "boolean", short: "h" },
101
- "output-format": { type: "string", short: "o", default: "table" },
156
+ json: { type: "boolean", short: "j" },
102
157
  "no-color": { type: "boolean" },
103
- watch: { type: "string", short: "w" }
158
+ watch: { type: "string", short: "w" },
159
+ notify: { type: "string", short: "n" }
104
160
  },
105
161
  strict: true
106
162
  });
@@ -112,17 +168,15 @@ Options:
112
168
  -h, --help Show this help message
113
169
  -w, --watch [interval] Update live every interval (default: 10s).
114
170
  Supports combined units: 20s, 5m, 1m20s.
115
- -o, --output-format <fmt> Output format: table (default), json
171
+ -n, --notify [threshold] Show a critical OS notification if any model falls below
172
+ the threshold (default: 20%; requires --watch).
173
+ -j, --json Output raw JSON instead of a table
116
174
  --no-color Disable color output
117
175
  `);
118
176
  return;
119
177
  }
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
178
  const isWatching = values.watch !== undefined;
179
+ const isJson = values.json === true;
126
180
  let intervalMs = 1e4;
127
181
  let intervalStr = "10s";
128
182
  if (values.watch) {
@@ -146,6 +200,20 @@ Options:
146
200
  }
147
201
  }
148
202
  const useColor = !values["no-color"] && !process.env.NO_COLOR && process.stdout.isTTY;
203
+ let notifyThreshold;
204
+ const lastFractions = new Map;
205
+ try {
206
+ notifyThreshold = parseNotifyThresholdArg(values.notify);
207
+ } catch (error) {
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ console.error(message);
210
+ process.exit(1);
211
+ }
212
+ const notifyRuntimeError = validateNotifyRuntime(notifyThreshold, isWatching, checkNotificationSupport());
213
+ if (notifyRuntimeError) {
214
+ console.error(notifyRuntimeError);
215
+ process.exit(1);
216
+ }
149
217
  const colors = {
150
218
  reset: useColor ? "\x1B[0m" : "",
151
219
  dim: useColor ? "\x1B[2m" : "",
@@ -242,11 +310,22 @@ Options:
242
310
  return vB.minor - vA.minor;
243
311
  return vB.suffix.localeCompare(vA.suffix);
244
312
  });
313
+ if (notifyThreshold !== null) {
314
+ for (const b of quotaData.buckets) {
315
+ const fraction = b.remainingFraction ?? 0;
316
+ const modelId = b.modelId;
317
+ const prevFraction = lastFractions.get(modelId);
318
+ if (shouldSendThresholdNotification(prevFraction, fraction, notifyThreshold)) {
319
+ sendNotification("Gemini Quota Alert", `Model ${modelId} has dropped below ${Math.round(notifyThreshold * 100)}%.`);
320
+ }
321
+ lastFractions.set(modelId, fraction);
322
+ }
323
+ }
245
324
  }
246
- if (isWatching)
325
+ if (isWatching && !isJson)
247
326
  process.stdout.write(colors.clear);
248
- if (outputFormat === "json") {
249
- console.log(JSON.stringify(quotaData.buckets || [], null, 2));
327
+ if (isJson) {
328
+ console.log(JSON.stringify(quotaData.buckets || []));
250
329
  } else {
251
330
  if (!quotaData.buckets || quotaData.buckets.length === 0) {
252
331
  console.log("No quota data available.");
@@ -286,7 +365,7 @@ Options:
286
365
  console.log("");
287
366
  }
288
367
  });
289
- if (isWatching) {
368
+ if (isWatching && !isJson) {
290
369
  console.log(`
291
370
  ${colors.dim}Updating every ${intervalStr}, press q to quit${colors.reset}`);
292
371
  }
@@ -374,5 +453,8 @@ async function refreshAccessToken(refreshToken) {
374
453
  return response.json();
375
454
  }
376
455
  export {
456
+ validateNotifyRuntime,
457
+ shouldSendThresholdNotification,
458
+ parseNotifyThresholdArg,
377
459
  main as default
378
460
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gusage",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "A standalone CLI to export Gemini CLI quota and usage statistics",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -18,7 +18,8 @@
18
18
  "scripts": {
19
19
  "format": "prettier --write .",
20
20
  "prepare": "husky",
21
- "build": "bun build ./index.ts --outfile dist/index.js && chmod +x dist/index.js"
21
+ "build": "bun build ./index.ts --outfile dist/index.js && chmod +x dist/index.js",
22
+ "prepack": "bun run build"
22
23
  },
23
24
  "lint-staged": {
24
25
  "*": "prettier --write --ignore-unknown",