agy-usage 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ name: publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ tags:
7
+ - "v*"
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ id-token: write
15
+ contents: read
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v5
19
+ - run: uv build
20
+ - run: uv publish
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ *.pyc
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: agy-usage
3
+ Version: 0.1.0
4
+ Summary: Antigravity CLI usage and quota monitor
5
+ Project-URL: Homepage, https://github.com/wakamex/agy-usage
6
+ Project-URL: Source, https://github.com/wakamex/agy-usage
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # agy-usage
11
+
12
+ Antigravity CLI usage and quota monitor. It mirrors the small `ccusage`,
13
+ `gemini-cli-usage`, and `codex-cli-usage` tools: dependency-free Python,
14
+ terminal output, JSON output, statusline output, and a cache-refresh daemon.
15
+
16
+ ## Example output
17
+
18
+ ```text
19
+ Project: agy-usage
20
+ Model: Gemini 3.5 Flash (High)
21
+ gemini-3.5-flash-high 12.4% used resets 1h05m
22
+ History: 24 entries, latest 2026-06-29T22:53:01+00:00
23
+ ```
24
+
25
+ Statusline:
26
+
27
+ ```text
28
+ q:12.4% reset:1h05m model:Gemini_3.5_Flash_(High)
29
+ ```
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ uv tool install agy-usage
35
+ ```
36
+
37
+ For local development from a checkout:
38
+
39
+ ```bash
40
+ uv tool install .
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ | Command | Description |
46
+ |---------|-------------|
47
+ | `agy-usage` | Show current usage |
48
+ | `agy-usage status` | Same as above |
49
+ | `agy-usage json` | Print raw JSON |
50
+ | `agy-usage statusline` | Compact statusline output |
51
+ | `agy-usage refresh` | Force a fresh fetch, rewrite cache, and print status |
52
+ | `agy-usage daemon [-i SECS]` | Keep the cache fresh in the foreground |
53
+ | `agy-usage install` | Print setup instructions |
54
+
55
+ ## Data sources
56
+
57
+ - Antigravity settings: `~/.gemini/antigravity-cli/settings.json`
58
+ - Antigravity OAuth token: `~/.gemini/antigravity-cli/antigravity-oauth-token`
59
+ - Antigravity command history: `~/.gemini/antigravity-cli/history.jsonl`
60
+ - Cache written by this tool: `~/.gemini/antigravity-cli/usage-limits.json`
61
+
62
+ Quota lookup uses the same Code Assist quota flow Antigravity logs mention:
63
+ `loadCodeAssist` followed by `retrieveUserQuota`.
64
+
65
+ ## Options
66
+
67
+ ```text
68
+ usage: agy-usage [-h] [--root ROOT] [-i INTERVAL] [--max-age MAX_AGE]
69
+ [--refresh]
70
+ {status,json,daemon,statusline,refresh,install}
71
+ ```
72
+
73
+ - `--root ROOT`: inspect a different project root instead of the current directory
74
+ - `--max-age MAX_AGE`: cache TTL for `statusline`
75
+ - `--refresh`: ignore the cache and rebuild fresh data where applicable
76
+
77
+ Environment overrides:
78
+
79
+ - `AGY_USAGE_FILE`: alternate cache path
80
+ - `AGY_ACCESS_TOKEN`: provide an access token instead of reading Antigravity state
81
+ - `AGY_CODE_ASSIST_BASE_URL`: alternate Code Assist base URL
@@ -0,0 +1,72 @@
1
+ # agy-usage
2
+
3
+ Antigravity CLI usage and quota monitor. It mirrors the small `ccusage`,
4
+ `gemini-cli-usage`, and `codex-cli-usage` tools: dependency-free Python,
5
+ terminal output, JSON output, statusline output, and a cache-refresh daemon.
6
+
7
+ ## Example output
8
+
9
+ ```text
10
+ Project: agy-usage
11
+ Model: Gemini 3.5 Flash (High)
12
+ gemini-3.5-flash-high 12.4% used resets 1h05m
13
+ History: 24 entries, latest 2026-06-29T22:53:01+00:00
14
+ ```
15
+
16
+ Statusline:
17
+
18
+ ```text
19
+ q:12.4% reset:1h05m model:Gemini_3.5_Flash_(High)
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ uv tool install agy-usage
26
+ ```
27
+
28
+ For local development from a checkout:
29
+
30
+ ```bash
31
+ uv tool install .
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ | Command | Description |
37
+ |---------|-------------|
38
+ | `agy-usage` | Show current usage |
39
+ | `agy-usage status` | Same as above |
40
+ | `agy-usage json` | Print raw JSON |
41
+ | `agy-usage statusline` | Compact statusline output |
42
+ | `agy-usage refresh` | Force a fresh fetch, rewrite cache, and print status |
43
+ | `agy-usage daemon [-i SECS]` | Keep the cache fresh in the foreground |
44
+ | `agy-usage install` | Print setup instructions |
45
+
46
+ ## Data sources
47
+
48
+ - Antigravity settings: `~/.gemini/antigravity-cli/settings.json`
49
+ - Antigravity OAuth token: `~/.gemini/antigravity-cli/antigravity-oauth-token`
50
+ - Antigravity command history: `~/.gemini/antigravity-cli/history.jsonl`
51
+ - Cache written by this tool: `~/.gemini/antigravity-cli/usage-limits.json`
52
+
53
+ Quota lookup uses the same Code Assist quota flow Antigravity logs mention:
54
+ `loadCodeAssist` followed by `retrieveUserQuota`.
55
+
56
+ ## Options
57
+
58
+ ```text
59
+ usage: agy-usage [-h] [--root ROOT] [-i INTERVAL] [--max-age MAX_AGE]
60
+ [--refresh]
61
+ {status,json,daemon,statusline,refresh,install}
62
+ ```
63
+
64
+ - `--root ROOT`: inspect a different project root instead of the current directory
65
+ - `--max-age MAX_AGE`: cache TTL for `statusline`
66
+ - `--refresh`: ignore the cache and rebuild fresh data where applicable
67
+
68
+ Environment overrides:
69
+
70
+ - `AGY_USAGE_FILE`: alternate cache path
71
+ - `AGY_ACCESS_TOKEN`: provide an access token instead of reading Antigravity state
72
+ - `AGY_CODE_ASSIST_BASE_URL`: alternate Code Assist base URL
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "agy-usage"
3
+ version = "0.1.0"
4
+ description = "Antigravity CLI usage and quota monitor"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [tool.uv]
10
+ package = true
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/agy_usage"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/wakamex/agy-usage"
21
+ Source = "https://github.com/wakamex/agy-usage"
22
+
23
+ [project.scripts]
24
+ agy-usage = "agy_usage:main"
25
+ agyusage = "agy_usage:main"
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env python3
2
+ """agy-usage - Antigravity CLI usage and quota monitor."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import signal
10
+ import sys
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from collections import Counter
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ AGY_DIR = Path.home() / ".gemini" / "antigravity-cli"
21
+ TOKEN_FILE = AGY_DIR / "antigravity-oauth-token"
22
+ SETTINGS_FILE = AGY_DIR / "settings.json"
23
+ HISTORY_FILE = AGY_DIR / "history.jsonl"
24
+ DEFAULT_USAGE_FILE = AGY_DIR / "usage-limits.json"
25
+
26
+ DAEMON_INTERVAL = 300
27
+ CACHE_MAX_AGE = 300
28
+ CODE_ASSIST_BASE_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal"
29
+
30
+ _TTY = sys.stdout.isatty()
31
+ _RED = "\033[0;31m" if _TTY else ""
32
+ _YELLOW = "\033[0;33m" if _TTY else ""
33
+ _GREEN = "\033[0;32m" if _TTY else ""
34
+ _DIM = "\033[0;90m" if _TTY else ""
35
+ _RESET = "\033[0m" if _TTY else ""
36
+
37
+
38
+ def _read_json(path: Path) -> dict | list | None:
39
+ try:
40
+ return json.loads(path.read_text())
41
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
42
+ return None
43
+
44
+
45
+ def _iso_now() -> str:
46
+ return datetime.now(UTC).isoformat()
47
+
48
+
49
+ def _parse_iso(timestamp: str | None) -> datetime | None:
50
+ if not timestamp:
51
+ return None
52
+ try:
53
+ parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
54
+ except ValueError:
55
+ return None
56
+ if parsed.tzinfo is None:
57
+ return parsed.replace(tzinfo=UTC)
58
+ return parsed
59
+
60
+
61
+ def _format_duration_until(iso_timestamp: str | None) -> str:
62
+ reset = _parse_iso(iso_timestamp)
63
+ if not reset:
64
+ return ""
65
+ seconds = int((reset - datetime.now(UTC)).total_seconds())
66
+ if seconds <= 0:
67
+ return ""
68
+ minutes = seconds // 60
69
+ if minutes >= 60:
70
+ return f"{minutes // 60}h{minutes % 60}m"
71
+ return f"{minutes}m"
72
+
73
+
74
+ def _format_pct(pct: float | int | None) -> str:
75
+ if pct is None:
76
+ return "?"
77
+ value = float(pct)
78
+ if value >= 1:
79
+ return f"{value:.1f}%"
80
+ return f"{value:.2f}%"
81
+
82
+
83
+ def _color_pct(pct: float | int | None) -> str:
84
+ if pct is None:
85
+ return "?"
86
+ value = float(pct)
87
+ color = _RED if value >= 70 else _YELLOW if value >= 40 else _GREEN
88
+ return f"{color}{_format_pct(value)}{_RESET}"
89
+
90
+
91
+ def get_usage_file() -> Path:
92
+ override = os.environ.get("AGY_USAGE_FILE") or os.environ.get("ANTIGRAVITY_USAGE_FILE")
93
+ return Path(override).expanduser() if override else DEFAULT_USAGE_FILE
94
+
95
+
96
+ def get_settings() -> dict:
97
+ data = _read_json(SETTINGS_FILE)
98
+ return data if isinstance(data, dict) else {}
99
+
100
+
101
+ def get_auth() -> dict | None:
102
+ data = _read_json(TOKEN_FILE)
103
+ return data if isinstance(data, dict) else None
104
+
105
+
106
+ def get_access_token() -> str:
107
+ env_token = os.environ.get("AGY_ACCESS_TOKEN") or os.environ.get("ANTIGRAVITY_ACCESS_TOKEN")
108
+ if env_token:
109
+ return env_token
110
+
111
+ auth = get_auth()
112
+ if not auth:
113
+ raise RuntimeError("No Antigravity OAuth token at ~/.gemini/antigravity-cli/antigravity-oauth-token")
114
+
115
+ token_payload = auth.get("token")
116
+ token = auth.get("access_token") or auth.get("AccessToken")
117
+ if isinstance(token_payload, dict):
118
+ token = token or token_payload.get("access_token") or token_payload.get("AccessToken")
119
+ elif isinstance(token_payload, str):
120
+ token = token or token_payload
121
+ if not isinstance(token, str) or not token:
122
+ raise RuntimeError("No access token in ~/.gemini/antigravity-cli/antigravity-oauth-token")
123
+ return token
124
+
125
+
126
+ def _code_assist_post(method: str, payload: dict, access_token: str) -> dict:
127
+ base_url = os.environ.get("AGY_CODE_ASSIST_BASE_URL", CODE_ASSIST_BASE_URL).rstrip("/")
128
+ req = urllib.request.Request(
129
+ f"{base_url}:{method}",
130
+ data=json.dumps(payload).encode(),
131
+ headers={
132
+ "Authorization": f"Bearer {access_token}",
133
+ "Content-Type": "application/json",
134
+ "Accept": "application/json",
135
+ "User-Agent": "agy-usage/0.1.0",
136
+ },
137
+ )
138
+ with urllib.request.urlopen(req, timeout=15) as resp:
139
+ return json.loads(resp.read())
140
+
141
+
142
+ def _load_code_assist(access_token: str) -> dict:
143
+ project_id = (
144
+ os.environ.get("GOOGLE_CLOUD_PROJECT")
145
+ or os.environ.get("GOOGLE_CLOUD_PROJECT_ID")
146
+ or _read_default_project_id()
147
+ )
148
+ metadata: dict[str, Any] = {
149
+ "ideType": "IDE_UNSPECIFIED",
150
+ "platform": "PLATFORM_UNSPECIFIED",
151
+ "pluginType": "GEMINI",
152
+ }
153
+ if project_id:
154
+ metadata["duetProject"] = project_id
155
+ return _code_assist_post(
156
+ "loadCodeAssist",
157
+ {
158
+ "cloudaicompanionProject": project_id,
159
+ "metadata": metadata,
160
+ },
161
+ access_token,
162
+ )
163
+
164
+
165
+ def _read_default_project_id() -> str | None:
166
+ try:
167
+ project_id = (AGY_DIR / "cache" / "default_project_id.txt").read_text().strip()
168
+ except OSError:
169
+ return None
170
+ return project_id or None
171
+
172
+
173
+ def _parse_quota_buckets(buckets: list[dict]) -> list[dict]:
174
+ parsed = []
175
+ for bucket in buckets:
176
+ remaining = None
177
+ limit = None
178
+ used_pct = None
179
+ remaining_fraction = bucket.get("remainingFraction")
180
+
181
+ try:
182
+ if bucket.get("remainingAmount") is not None:
183
+ remaining = int(bucket["remainingAmount"])
184
+ except (TypeError, ValueError):
185
+ remaining = None
186
+
187
+ if isinstance(remaining_fraction, int | float):
188
+ used_pct = (1 - float(remaining_fraction)) * 100
189
+ if remaining is not None and isinstance(remaining_fraction, int | float):
190
+ if remaining_fraction > 0:
191
+ limit = round(remaining / float(remaining_fraction))
192
+
193
+ parsed.append(
194
+ {
195
+ "model": bucket.get("modelId") or bucket.get("model"),
196
+ "remaining": remaining,
197
+ "limit": limit,
198
+ "used_pct": used_pct,
199
+ "remaining_fraction": remaining_fraction,
200
+ "reset_time": bucket.get("resetTime"),
201
+ "token_type": bucket.get("tokenType"),
202
+ "disabled": bool(bucket.get("disabled", False)),
203
+ }
204
+ )
205
+ return parsed
206
+
207
+
208
+ def _select_summary_bucket(quota: dict) -> dict | None:
209
+ buckets = quota.get("buckets") or []
210
+ if not isinstance(buckets, list):
211
+ return None
212
+ active = [bucket for bucket in buckets if not bucket.get("disabled")]
213
+ scored = [bucket for bucket in active if bucket.get("used_pct") is not None]
214
+ if scored:
215
+ return max(scored, key=lambda bucket: bucket["used_pct"])
216
+ return active[0] if active else (buckets[0] if buckets else None)
217
+
218
+
219
+ def fetch_quota() -> dict:
220
+ access_token = get_access_token()
221
+ load_res = _load_code_assist(access_token)
222
+
223
+ project_id = (
224
+ load_res.get("cloudaicompanionProject")
225
+ or os.environ.get("GOOGLE_CLOUD_PROJECT")
226
+ or os.environ.get("GOOGLE_CLOUD_PROJECT_ID")
227
+ or _read_default_project_id()
228
+ )
229
+ if not project_id:
230
+ raise RuntimeError("No Code Assist project ID available. Set GOOGLE_CLOUD_PROJECT if needed.")
231
+
232
+ quota_res = _code_assist_post("retrieveUserQuota", {"project": project_id}, access_token)
233
+ current_tier = load_res.get("currentTier") or {}
234
+ paid_tier = load_res.get("paidTier") or {}
235
+ result = {
236
+ "project_id": project_id,
237
+ "user_tier": paid_tier.get("id") or current_tier.get("id"),
238
+ "user_tier_name": paid_tier.get("name") or current_tier.get("name"),
239
+ "buckets": _parse_quota_buckets(quota_res.get("buckets") or []),
240
+ }
241
+ result["summary_bucket"] = _select_summary_bucket(result)
242
+ credits = quota_res.get("credits") or quota_res.get("g1Credits")
243
+ if credits is not None:
244
+ result["credits"] = credits
245
+ return result
246
+
247
+
248
+ def read_history_summary(path: Path = HISTORY_FILE) -> dict:
249
+ commands: Counter[str] = Counter()
250
+ workspaces: Counter[str] = Counter()
251
+ total = 0
252
+ latest_ms: int | None = None
253
+
254
+ try:
255
+ lines = path.read_text().splitlines()
256
+ except OSError:
257
+ lines = []
258
+
259
+ for line in lines:
260
+ try:
261
+ item = json.loads(line)
262
+ except json.JSONDecodeError:
263
+ continue
264
+ if not isinstance(item, dict):
265
+ continue
266
+ total += 1
267
+ display = item.get("display")
268
+ workspace = item.get("workspace")
269
+ timestamp = item.get("timestamp")
270
+ if isinstance(display, str):
271
+ commands[display] += 1
272
+ if isinstance(workspace, str):
273
+ workspaces[workspace] += 1
274
+ if isinstance(timestamp, int):
275
+ latest_ms = timestamp if latest_ms is None else max(latest_ms, timestamp)
276
+
277
+ latest_at = (
278
+ datetime.fromtimestamp(latest_ms / 1000, tz=UTC).isoformat()
279
+ if latest_ms is not None
280
+ else None
281
+ )
282
+ return {
283
+ "entries": total,
284
+ "latest_at": latest_at,
285
+ "top_commands": dict(commands.most_common(10)),
286
+ "top_workspaces": dict(workspaces.most_common(10)),
287
+ }
288
+
289
+
290
+ def build_usage_json(project_root: Path | None = None) -> dict:
291
+ root = (project_root or Path.cwd()).resolve()
292
+ settings = get_settings()
293
+ result = {
294
+ "project_root": str(root),
295
+ "model": settings.get("model"),
296
+ "source": [],
297
+ "updated_at": _iso_now(),
298
+ "history": read_history_summary(),
299
+ }
300
+ if result["history"]["entries"]:
301
+ result["source"].append("history")
302
+
303
+ try:
304
+ result["account_quota"] = fetch_quota()
305
+ result["source"].append("quota_api")
306
+ except Exception as exc:
307
+ result["quota_error"] = str(exc)
308
+
309
+ return result
310
+
311
+
312
+ def write_usage_file(data: dict):
313
+ usage_file = get_usage_file()
314
+ usage_file.parent.mkdir(parents=True, exist_ok=True)
315
+ usage_file.write_text(json.dumps(data, indent=2) + "\n")
316
+
317
+
318
+ def _print_status(data: dict):
319
+ print(f"Project: {Path(data['project_root']).name}")
320
+ model = data.get("model")
321
+ if model:
322
+ print(f"Model: {model}")
323
+
324
+ quota = data.get("account_quota")
325
+ if quota:
326
+ bucket_names = [
327
+ (bucket.get("model") or "unknown")
328
+ for bucket in quota.get("buckets", [])
329
+ if isinstance(bucket, dict)
330
+ ]
331
+ name_width = max(map(len, bucket_names), default=len("Quota"))
332
+ for bucket in quota.get("buckets", []):
333
+ model_name = bucket.get("model") or "unknown"
334
+ if bucket.get("disabled"):
335
+ print(f" {model_name:{name_width}s} {_DIM}disabled{_RESET}")
336
+ continue
337
+ reset_time = _format_duration_until(bucket.get("reset_time"))
338
+ reset_part = f" resets {reset_time}" if reset_time else ""
339
+ remaining = bucket.get("remaining")
340
+ limit = bucket.get("limit")
341
+ remain_part = (
342
+ f" {remaining} / {limit} remaining"
343
+ if remaining is not None and limit is not None
344
+ else ""
345
+ )
346
+ print(
347
+ f" {model_name:{name_width}s} {_color_pct(bucket.get('used_pct'))} used"
348
+ f"{remain_part}{_DIM}{reset_part}{_RESET}"
349
+ )
350
+ elif data.get("quota_error"):
351
+ print(f" {'Quota':20s} {_DIM}{data['quota_error']}{_RESET}")
352
+
353
+ history = data.get("history") or {}
354
+ if history.get("entries"):
355
+ latest = history.get("latest_at") or "unknown"
356
+ print(f"History: {history['entries']} entries, latest {latest}")
357
+
358
+
359
+ def _statusline_text(data: dict) -> str:
360
+ parts = []
361
+ quota = data.get("account_quota")
362
+ if quota and quota.get("summary_bucket"):
363
+ summary = quota["summary_bucket"]
364
+ if summary.get("disabled"):
365
+ parts.append("q:disabled")
366
+ elif summary.get("used_pct") is not None:
367
+ parts.append(f"q:{_format_pct(summary['used_pct'])}")
368
+ reset_time = _format_duration_until(summary.get("reset_time"))
369
+ if reset_time:
370
+ parts.append(f"reset:{reset_time}")
371
+ elif data.get("quota_error"):
372
+ parts.append("q:err")
373
+
374
+ model = data.get("model")
375
+ if model:
376
+ parts.append(f"model:{model.replace(' ', '_')}")
377
+ return " ".join(parts)
378
+
379
+
380
+ def _get_cached_usage(
381
+ project_root: Path | None = None,
382
+ max_age: int = CACHE_MAX_AGE,
383
+ force_refresh: bool = False,
384
+ ) -> dict:
385
+ usage_file = get_usage_file()
386
+ root = str((project_root or Path.cwd()).resolve())
387
+ if not force_refresh:
388
+ try:
389
+ cached = json.loads(usage_file.read_text())
390
+ updated = _parse_iso(cached.get("updated_at"))
391
+ if updated and cached.get("project_root") == root:
392
+ age = (datetime.now(UTC) - updated).total_seconds()
393
+ if age < max_age and "quota_api" in cached.get("source", []):
394
+ return cached
395
+ except Exception:
396
+ pass
397
+
398
+ try:
399
+ fresh = build_usage_json(project_root)
400
+ write_usage_file(fresh)
401
+ return fresh
402
+ except Exception:
403
+ try:
404
+ return json.loads(usage_file.read_text())
405
+ except Exception:
406
+ return build_usage_json(project_root)
407
+
408
+
409
+ def cmd_status(args):
410
+ data = build_usage_json(project_root=Path(args.root).resolve() if args.root else None)
411
+ _print_status(data)
412
+
413
+
414
+ def cmd_json(args):
415
+ data = build_usage_json(project_root=Path(args.root).resolve() if args.root else None)
416
+ print(json.dumps(data, indent=2))
417
+
418
+
419
+ def cmd_daemon(args):
420
+ signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
421
+ if hasattr(signal, "SIGTERM"):
422
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
423
+
424
+ root = Path(args.root).resolve() if args.root else None
425
+ usage_file = get_usage_file()
426
+
427
+ print(f"agy-usage daemon started (refreshing every {args.interval}s)")
428
+ print(f"Writing to {usage_file}")
429
+
430
+ while True:
431
+ try:
432
+ data = build_usage_json(project_root=root)
433
+ write_usage_file(data)
434
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
435
+ except Exception as exc:
436
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] Error: {exc}", file=sys.stderr)
437
+ time.sleep(args.interval)
438
+
439
+
440
+ def cmd_statusline(args):
441
+ data = _get_cached_usage(
442
+ project_root=Path(args.root).resolve() if args.root else None,
443
+ max_age=args.max_age,
444
+ force_refresh=args.refresh,
445
+ )
446
+ print(_statusline_text(data))
447
+
448
+
449
+ def cmd_refresh(args):
450
+ data = build_usage_json(project_root=Path(args.root).resolve() if args.root else None)
451
+ write_usage_file(data)
452
+ _print_status(data)
453
+
454
+
455
+ def cmd_install(_args):
456
+ print(
457
+ "Install with:\n"
458
+ " uv tool install agy-usage\n\n"
459
+ "For local development:\n"
460
+ " uv tool install .\n\n"
461
+ "Then run:\n"
462
+ " agy-usage\n"
463
+ " agy-usage statusline\n"
464
+ " agy-usage refresh\n"
465
+ )
466
+
467
+
468
+ def _build_parser() -> argparse.ArgumentParser:
469
+ parser = argparse.ArgumentParser(description="Antigravity CLI usage and quota monitor")
470
+ parser.add_argument(
471
+ "command",
472
+ nargs="?",
473
+ default="status",
474
+ choices=["status", "json", "daemon", "statusline", "refresh", "install"],
475
+ )
476
+ parser.add_argument("--root", help="Project root to inspect (default: current working directory)")
477
+ parser.add_argument(
478
+ "-i",
479
+ "--interval",
480
+ type=int,
481
+ default=DAEMON_INTERVAL,
482
+ help="Daemon refresh interval in seconds",
483
+ )
484
+ parser.add_argument(
485
+ "--max-age",
486
+ type=int,
487
+ default=CACHE_MAX_AGE,
488
+ help="Maximum cache age in seconds for statusline",
489
+ )
490
+ parser.add_argument(
491
+ "--refresh",
492
+ action="store_true",
493
+ help="Ignore cache and force a fresh fetch where applicable",
494
+ )
495
+ return parser
496
+
497
+
498
+ def main():
499
+ parser = _build_parser()
500
+ args = parser.parse_args()
501
+ commands = {
502
+ "status": cmd_status,
503
+ "json": cmd_json,
504
+ "daemon": cmd_daemon,
505
+ "statusline": cmd_statusline,
506
+ "refresh": cmd_refresh,
507
+ "install": cmd_install,
508
+ }
509
+ commands[args.command](args)
510
+
511
+
512
+ if __name__ == "__main__":
513
+ main()
@@ -0,0 +1,3 @@
1
+ from agy_usage import main
2
+
3
+ main()
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import unittest
7
+ from datetime import UTC, datetime, timedelta
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ import agy_usage
12
+
13
+
14
+ def _write_json(path: Path, payload: dict):
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ path.write_text(json.dumps(payload) + "\n")
17
+
18
+
19
+ class AgyUsageTests(unittest.TestCase):
20
+ def test_parse_quota_buckets_computes_usage_and_limit(self):
21
+ buckets = agy_usage._parse_quota_buckets(
22
+ [
23
+ {
24
+ "modelId": "gemini-test",
25
+ "remainingAmount": "25",
26
+ "remainingFraction": 0.5,
27
+ "resetTime": "2026-06-30T01:00:00Z",
28
+ }
29
+ ]
30
+ )
31
+
32
+ self.assertEqual(buckets[0]["model"], "gemini-test")
33
+ self.assertEqual(buckets[0]["remaining"], 25)
34
+ self.assertEqual(buckets[0]["limit"], 50)
35
+ self.assertEqual(buckets[0]["used_pct"], 50)
36
+
37
+ def test_summary_bucket_skips_disabled_and_picks_highest_used(self):
38
+ quota = {
39
+ "buckets": [
40
+ {"model": "disabled", "used_pct": 99, "disabled": True},
41
+ {"model": "flash", "used_pct": 10},
42
+ {"model": "pro", "used_pct": 75},
43
+ ]
44
+ }
45
+
46
+ self.assertEqual(agy_usage._select_summary_bucket(quota)["model"], "pro")
47
+
48
+ def test_read_history_summary_counts_commands_and_workspaces(self):
49
+ with tempfile.TemporaryDirectory() as tmp:
50
+ history = Path(tmp) / "history.jsonl"
51
+ history.write_text(
52
+ json.dumps({"display": "/usage", "timestamp": 1_000, "workspace": "/code/a"}) + "\n"
53
+ + "not json\n"
54
+ + json.dumps({"display": "/model", "timestamp": 2_000, "workspace": "/code/a"}) + "\n"
55
+ + json.dumps({"display": "/usage", "timestamp": 3_000, "workspace": "/code/b"}) + "\n"
56
+ )
57
+
58
+ summary = agy_usage.read_history_summary(history)
59
+
60
+ self.assertEqual(summary["entries"], 3)
61
+ self.assertEqual(summary["top_commands"]["/usage"], 2)
62
+ self.assertEqual(summary["top_workspaces"]["/code/a"], 2)
63
+ self.assertEqual(summary["latest_at"], "1970-01-01T00:00:03+00:00")
64
+
65
+ def test_access_token_can_come_from_env(self):
66
+ with mock.patch.dict(os.environ, {"AGY_ACCESS_TOKEN": "token"}, clear=True):
67
+ self.assertEqual(agy_usage.get_access_token(), "token")
68
+
69
+ def test_access_token_reads_nested_antigravity_token_file(self):
70
+ with tempfile.TemporaryDirectory() as tmp:
71
+ token_file = Path(tmp) / "antigravity-oauth-token"
72
+ _write_json(
73
+ token_file,
74
+ {
75
+ "auth_method": "consumer",
76
+ "token": {
77
+ "access_token": "nested-token",
78
+ "token_type": "Bearer",
79
+ },
80
+ },
81
+ )
82
+
83
+ with (
84
+ mock.patch.object(agy_usage, "TOKEN_FILE", token_file),
85
+ mock.patch.dict(os.environ, {}, clear=True),
86
+ ):
87
+ self.assertEqual(agy_usage.get_access_token(), "nested-token")
88
+
89
+ def test_build_usage_json_includes_history_when_quota_fails(self):
90
+ with tempfile.TemporaryDirectory() as tmp:
91
+ tmp_path = Path(tmp)
92
+ history = tmp_path / "history.jsonl"
93
+ settings = tmp_path / "settings.json"
94
+ _write_json(settings, {"model": "Gemini Test"})
95
+ history.write_text(json.dumps({"display": "/usage", "timestamp": 1_000}) + "\n")
96
+
97
+ with (
98
+ mock.patch.object(agy_usage, "HISTORY_FILE", history),
99
+ mock.patch.object(agy_usage, "SETTINGS_FILE", settings),
100
+ mock.patch.object(agy_usage, "fetch_quota", side_effect=RuntimeError("no quota")),
101
+ ):
102
+ usage = agy_usage.build_usage_json(tmp_path)
103
+
104
+ self.assertEqual(usage["model"], "Gemini Test")
105
+ self.assertIn("history", usage["source"])
106
+ self.assertEqual(usage["quota_error"], "no quota")
107
+
108
+ def test_force_refresh_bypasses_cache(self):
109
+ with tempfile.TemporaryDirectory() as tmp:
110
+ tmp_path = Path(tmp)
111
+ project_root = tmp_path / "project"
112
+ project_root.mkdir()
113
+ usage_file = tmp_path / "usage-limits.json"
114
+ cached = {
115
+ "project_root": str(project_root.resolve()),
116
+ "source": ["quota_api"],
117
+ "updated_at": datetime.now(UTC).isoformat(),
118
+ }
119
+ fresh = {
120
+ "project_root": str(project_root.resolve()),
121
+ "source": ["quota_api"],
122
+ "updated_at": (datetime.now(UTC) + timedelta(seconds=1)).isoformat(),
123
+ }
124
+ usage_file.write_text(json.dumps(cached) + "\n")
125
+
126
+ with (
127
+ mock.patch.object(agy_usage, "DEFAULT_USAGE_FILE", usage_file),
128
+ mock.patch.dict(os.environ, {}, clear=True),
129
+ mock.patch.object(agy_usage, "build_usage_json", return_value=fresh) as build_mock,
130
+ ):
131
+ result = agy_usage._get_cached_usage(project_root=project_root)
132
+ self.assertEqual(result, cached)
133
+ build_mock.assert_not_called()
134
+
135
+ result = agy_usage._get_cached_usage(project_root=project_root, force_refresh=True)
136
+
137
+ self.assertEqual(result, fresh)
138
+ self.assertEqual(json.loads(usage_file.read_text()), fresh)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ unittest.main()
@@ -0,0 +1,20 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [options]
6
+ exclude-newer = "2026-06-23T03:29:12.769788954Z"
7
+ exclude-newer-span = "P7D"
8
+
9
+ [options.exclude-newer-package]
10
+ clanker-analytics = false
11
+ ghp = false
12
+ search-claude-history = false
13
+ gemini-cli-usage = false
14
+ ccusage = false
15
+ codex-cli-usage = false
16
+
17
+ [[package]]
18
+ name = "agy-usage"
19
+ version = "0.1.0"
20
+ source = { editable = "." }