quotacli-mac 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.
Files changed (40) hide show
  1. quotacli_mac-0.1.0/.github/workflows/ci.yml +20 -0
  2. quotacli_mac-0.1.0/.gitignore +9 -0
  3. quotacli_mac-0.1.0/LICENSE +21 -0
  4. quotacli_mac-0.1.0/PKG-INFO +99 -0
  5. quotacli_mac-0.1.0/README.md +66 -0
  6. quotacli_mac-0.1.0/pyproject.toml +46 -0
  7. quotacli_mac-0.1.0/src/quotacli/__init__.py +3 -0
  8. quotacli_mac-0.1.0/src/quotacli/__main__.py +4 -0
  9. quotacli_mac-0.1.0/src/quotacli/backoff.py +52 -0
  10. quotacli_mac-0.1.0/src/quotacli/cli.py +171 -0
  11. quotacli_mac-0.1.0/src/quotacli/httpclient.py +18 -0
  12. quotacli_mac-0.1.0/src/quotacli/keychain.py +71 -0
  13. quotacli_mac-0.1.0/src/quotacli/protocol.py +60 -0
  14. quotacli_mac-0.1.0/src/quotacli/providers/__init__.py +21 -0
  15. quotacli_mac-0.1.0/src/quotacli/providers/antigravity.py +339 -0
  16. quotacli_mac-0.1.0/src/quotacli/providers/claude_code.py +225 -0
  17. quotacli_mac-0.1.0/src/quotacli/providers/codex.py +193 -0
  18. quotacli_mac-0.1.0/src/quotacli/providers/cursor.py +175 -0
  19. quotacli_mac-0.1.0/src/quotacli/providers/grok.py +175 -0
  20. quotacli_mac-0.1.0/src/quotacli/render.py +205 -0
  21. quotacli_mac-0.1.0/src/quotacli/state.py +143 -0
  22. quotacli_mac-0.1.0/tests/__init__.py +0 -0
  23. quotacli_mac-0.1.0/tests/fixtures/antigravity_quota_summary_bridge.json +44 -0
  24. quotacli_mac-0.1.0/tests/fixtures/antigravity_quota_summary_fallback.json +11 -0
  25. quotacli_mac-0.1.0/tests/fixtures/claude_code_usage.json +30 -0
  26. quotacli_mac-0.1.0/tests/fixtures/claude_code_usage_fallback_merge.json +21 -0
  27. quotacli_mac-0.1.0/tests/fixtures/codex_usage.json +20 -0
  28. quotacli_mac-0.1.0/tests/fixtures/codex_usage_30d_free.json +9 -0
  29. quotacli_mac-0.1.0/tests/fixtures/cursor_usage_summary.json +18 -0
  30. quotacli_mac-0.1.0/tests/fixtures/grok_billing.json +15 -0
  31. quotacli_mac-0.1.0/tests/providers/__init__.py +0 -0
  32. quotacli_mac-0.1.0/tests/providers/test_antigravity_parsing.py +72 -0
  33. quotacli_mac-0.1.0/tests/providers/test_claude_code_parsing.py +64 -0
  34. quotacli_mac-0.1.0/tests/providers/test_codex_parsing.py +100 -0
  35. quotacli_mac-0.1.0/tests/providers/test_cursor_parsing.py +100 -0
  36. quotacli_mac-0.1.0/tests/providers/test_grok_parsing.py +64 -0
  37. quotacli_mac-0.1.0/tests/test_backoff.py +65 -0
  38. quotacli_mac-0.1.0/tests/test_cli.py +82 -0
  39. quotacli_mac-0.1.0/tests/test_render_compact.py +70 -0
  40. quotacli_mac-0.1.0/tests/test_state.py +77 -0
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: macos-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: pytest -q
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .coverage
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lokesh Devnani
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.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.5
2
+ Name: quotacli-mac
3
+ Version: 0.1.0
4
+ Summary: Terminal quota tracker for Claude Code, Cursor, Codex, Antigravity, and Grok
5
+ Project-URL: Homepage, https://github.com/lokeshdevnani/quotacli
6
+ Project-URL: Repository, https://github.com/lokeshdevnani/quotacli
7
+ Project-URL: Issues, https://github.com/lokeshdevnani/quotacli/issues
8
+ Author-email: Lokesh Devnani <lokeshdevnani@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: antigravity,claude-code,cli,codex,cursor,grok,quota,rate-limit,usage
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Topic :: Utilities
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pyobjc-framework-security>=10.0; sys_platform == 'darwin'
26
+ Requires-Dist: rich>=13.7
27
+ Provides-Extra: dev
28
+ Requires-Dist: build; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Requires-Dist: twine; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # quotacli
35
+
36
+ Terminal quota tracker for Claude Code, Cursor, Codex, Antigravity, and Grok.
37
+ Reads the credentials each tool already stores locally on your Mac — no new
38
+ sign-in flow, nothing sent anywhere except each provider's own usage endpoint.
39
+
40
+ macOS only: every provider's read mechanism (Keychain, `~/Library/...` paths,
41
+ `lsof`/`ps` process discovery) is macOS-specific.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ python3 -m venv .venv
47
+ source .venv/bin/activate
48
+ pip install -e .
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```
54
+ quotacli # one-shot table, all 5 providers
55
+ quotacli --watch [--interval 30] # live-refreshing view, like top
56
+ quotacli --json # machine-readable output
57
+ quotacli --provider claude-code # limit to one or more providers (repeatable)
58
+ quotacli --provider antigravity -v # report which of Antigravity's 4 data-source tiers answered
59
+ ```
60
+
61
+ State (last-known-good snapshots, and Claude Code/Codex 429 backoff
62
+ deadlines) persists at `~/.config/quotacli/state.json`.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ pip install -e ".[dev]"
68
+ pytest
69
+ ```
70
+
71
+ All 5 providers' undocumented, reverse-engineered endpoints can drift without
72
+ notice. The pure response-parsing function per provider
73
+ (`providers/<name>.py::parse_*`) is unit-tested against frozen fixtures in
74
+ `tests/fixtures/` — periodically run `quotacli <provider-id>` live and diff
75
+ against those fixtures to catch upstream API changes.
76
+
77
+ What isn't (and can't be) covered by the unit tests:
78
+
79
+ - **Keychain enumeration** (Claude Code, Antigravity) — needs a machine with
80
+ the real tool installed and signed in. Claude Code specifically rotates
81
+ its keychain item on every token refresh instead of updating one in
82
+ place, so the "pick the newest of several duplicates by modification
83
+ date" logic in `keychain.py` can only be validated against a real account
84
+ that's rotated at least once.
85
+ - **Antigravity's tier-2 local bridge** (`ps`/`lsof` process discovery,
86
+ scoped self-signed-TLS bypass) — only testable with Antigravity actually
87
+ running. Use `quotacli --provider antigravity -v` to see which of the 4
88
+ tiers answered.
89
+ - **429 backoff persisted across real repeated invocations** — the backoff
90
+ math and state round-trip are both unit-tested in isolation and trusted
91
+ to compose correctly; forcing a real 429 against the live API to test the
92
+ full path end-to-end isn't practical.
93
+
94
+ ## Not yet built
95
+
96
+ A `caffeinate`-style subcommand (keep the Mac awake while a tracked
97
+ provider's CLI is actively running) is planned but intentionally deferred —
98
+ the CLI is a `click.Group` from the start so it can be added later without
99
+ restructuring the entrypoint.
@@ -0,0 +1,66 @@
1
+ # quotacli
2
+
3
+ Terminal quota tracker for Claude Code, Cursor, Codex, Antigravity, and Grok.
4
+ Reads the credentials each tool already stores locally on your Mac — no new
5
+ sign-in flow, nothing sent anywhere except each provider's own usage endpoint.
6
+
7
+ macOS only: every provider's read mechanism (Keychain, `~/Library/...` paths,
8
+ `lsof`/`ps` process discovery) is macOS-specific.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ python3 -m venv .venv
14
+ source .venv/bin/activate
15
+ pip install -e .
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```
21
+ quotacli # one-shot table, all 5 providers
22
+ quotacli --watch [--interval 30] # live-refreshing view, like top
23
+ quotacli --json # machine-readable output
24
+ quotacli --provider claude-code # limit to one or more providers (repeatable)
25
+ quotacli --provider antigravity -v # report which of Antigravity's 4 data-source tiers answered
26
+ ```
27
+
28
+ State (last-known-good snapshots, and Claude Code/Codex 429 backoff
29
+ deadlines) persists at `~/.config/quotacli/state.json`.
30
+
31
+ ## Development
32
+
33
+ ```bash
34
+ pip install -e ".[dev]"
35
+ pytest
36
+ ```
37
+
38
+ All 5 providers' undocumented, reverse-engineered endpoints can drift without
39
+ notice. The pure response-parsing function per provider
40
+ (`providers/<name>.py::parse_*`) is unit-tested against frozen fixtures in
41
+ `tests/fixtures/` — periodically run `quotacli <provider-id>` live and diff
42
+ against those fixtures to catch upstream API changes.
43
+
44
+ What isn't (and can't be) covered by the unit tests:
45
+
46
+ - **Keychain enumeration** (Claude Code, Antigravity) — needs a machine with
47
+ the real tool installed and signed in. Claude Code specifically rotates
48
+ its keychain item on every token refresh instead of updating one in
49
+ place, so the "pick the newest of several duplicates by modification
50
+ date" logic in `keychain.py` can only be validated against a real account
51
+ that's rotated at least once.
52
+ - **Antigravity's tier-2 local bridge** (`ps`/`lsof` process discovery,
53
+ scoped self-signed-TLS bypass) — only testable with Antigravity actually
54
+ running. Use `quotacli --provider antigravity -v` to see which of the 4
55
+ tiers answered.
56
+ - **429 backoff persisted across real repeated invocations** — the backoff
57
+ math and state round-trip are both unit-tested in isolation and trusted
58
+ to compose correctly; forcing a real 429 against the live API to test the
59
+ full path end-to-end isn't practical.
60
+
61
+ ## Not yet built
62
+
63
+ A `caffeinate`-style subcommand (keep the Mac awake while a tracked
64
+ provider's CLI is actively running) is planned but intentionally deferred —
65
+ the CLI is a `click.Group` from the start so it can be added later without
66
+ restructuring the entrypoint.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "quotacli-mac"
3
+ version = "0.1.0"
4
+ description = "Terminal quota tracker for Claude Code, Cursor, Codex, Antigravity, and Grok"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ authors = [{ name = "Lokesh Devnani", email = "lokeshdevnani@gmail.com" }]
10
+ keywords = ["cli", "claude-code", "cursor", "codex", "antigravity", "grok", "quota", "rate-limit", "usage"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: MacOS :: MacOS X",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Software Development",
20
+ "Topic :: Utilities",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "click>=8.1",
25
+ "rich>=13.7",
26
+ "httpx>=0.27",
27
+ "pyobjc-framework-Security>=10.0; sys_platform == 'darwin'",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/lokeshdevnani/quotacli"
32
+ Repository = "https://github.com/lokeshdevnani/quotacli"
33
+ Issues = "https://github.com/lokeshdevnani/quotacli/issues"
34
+
35
+ [project.scripts]
36
+ quotacli = "quotacli.cli:main"
37
+
38
+ [project.optional-dependencies]
39
+ dev = ["pytest>=8", "pytest-cov", "build", "twine"]
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/quotacli"]
@@ -0,0 +1,3 @@
1
+ """quotacli — terminal quota tracker for AI coding-assistant CLIs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from quotacli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,52 @@
1
+ """Pure backoff/retry-after math, shared by the Claude Code and Codex
2
+ providers. Kept dependency-free and side-effect-free so it's trivially
3
+ unit-testable without credentials, network, or state.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import datetime as dt
9
+ from email.utils import parsedate_to_datetime
10
+
11
+
12
+ def parse_retry_after(header_value: str | None) -> float:
13
+ """Parse a Retry-After header into seconds (>=0).
14
+
15
+ Accepts either a plain integer-seconds string or an HTTP-date string.
16
+ Missing or unparseable input returns 0.0 — callers must never trust this
17
+ value alone (some APIs send `Retry-After: 0`), only use it to raise a
18
+ floor computed independently.
19
+ """
20
+ if not header_value:
21
+ return 0.0
22
+ header_value = header_value.strip()
23
+ if header_value.isdigit():
24
+ return float(header_value)
25
+ try:
26
+ when = parsedate_to_datetime(header_value)
27
+ except (TypeError, ValueError):
28
+ return 0.0
29
+ if when.tzinfo is None:
30
+ when = when.replace(tzinfo=dt.timezone.utc)
31
+ delta = (when - dt.datetime.now(dt.timezone.utc)).total_seconds()
32
+ return max(0.0, delta)
33
+
34
+
35
+ def claude_code_backoff_seconds(attempt: int, retry_after_seconds: float) -> float:
36
+ """Exponential backoff for Claude Code's /usage endpoint.
37
+
38
+ Floor starts at 60s and doubles per consecutive 429 (attempt is
39
+ 0-indexed on the first 429), capped at attempt index 4 (60*2^4=960,
40
+ clamped to the overall 900s/15min ceiling). The server's Retry-After
41
+ header only ever raises this floor, never overrides it — Anthropic's
42
+ endpoint has been observed sending `Retry-After: 0`.
43
+ """
44
+ floor = min(900.0, 60.0 * (2 ** min(attempt, 4)))
45
+ return max(floor, retry_after_seconds)
46
+
47
+
48
+ def codex_backoff_seconds(retry_after_seconds: float) -> float:
49
+ """Flat backoff for Codex's usage endpoint: minimum 60s, or the
50
+ server's Retry-After value if it asks for longer. No exponential
51
+ doubling on repeated 429s (simpler than Claude Code's scheme)."""
52
+ return max(60.0, retry_after_seconds)
@@ -0,0 +1,171 @@
1
+ """quotacli entrypoint. A click Group (not a bare command) so a future
2
+ `quotacli caffeinate` subcommand can be added later without restructuring.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+
10
+ import click
11
+ from rich.live import Live
12
+
13
+ from quotacli.protocol import ErrorKind, Provider, ProviderError
14
+ from quotacli.providers import build_providers
15
+ from quotacli.render import RenderRow, build_compact, build_json, build_table, console
16
+ from quotacli.state import StateStore
17
+
18
+ DEFAULT_INTERVAL = 30
19
+
20
+
21
+ def _safe_fetch(provider: Provider, store: StateStore) -> RenderRow:
22
+ try:
23
+ snapshot = provider.fetch_snapshot()
24
+ return RenderRow.from_snapshot(provider.id, provider.display_name, snapshot)
25
+ except ProviderError as exc:
26
+ cached = store.get_cached_snapshot(provider.id)
27
+ return RenderRow.from_error(provider.id, provider.display_name, exc, cached)
28
+
29
+
30
+ def gather_rows(providers: list[Provider], store: StateStore) -> list[RenderRow]:
31
+ order = {p.id: i for i, p in enumerate(providers)}
32
+ rows: list[RenderRow] = []
33
+ with ThreadPoolExecutor(max_workers=max(1, len(providers))) as pool:
34
+ futures = {pool.submit(_safe_fetch, p, store): p for p in providers}
35
+ for future in as_completed(futures):
36
+ rows.append(future.result())
37
+ rows.sort(key=lambda r: order[r.provider_id])
38
+ return rows
39
+
40
+
41
+ def _visible_rows(rows: list[RenderRow], *, show_all: bool, explicit_ids: set[str]) -> list[RenderRow]:
42
+ """Hide providers that have never been signed in and have nothing
43
+ cached to show — pure noise if you don't use that tool. A provider
44
+ named explicitly via --provider is always shown regardless (asking for
45
+ it by name is an unambiguous request to see it, even if unauthenticated),
46
+ and --all bypasses this filter entirely."""
47
+ if show_all:
48
+ return rows
49
+ visible = []
50
+ for row in rows:
51
+ if row.provider_id in explicit_ids:
52
+ visible.append(row)
53
+ continue
54
+ if (
55
+ row.snapshot is None
56
+ and row.error is not None
57
+ and row.error.kind == ErrorKind.NEEDS_AUTH
58
+ ):
59
+ continue
60
+ visible.append(row)
61
+ return visible
62
+
63
+
64
+ def _print_verbose_tiers(providers: list[Provider]) -> None:
65
+ """Antigravity's bridge-discovery tier can't be meaningfully unit-tested
66
+ end-to-end, so -v reports which of its 4 tiers actually answered."""
67
+ for provider in providers:
68
+ tier = getattr(provider, "last_tier", None)
69
+ if tier is not None:
70
+ console.print(f"[dim]{provider.display_name}: answered via {tier}[/dim]", highlight=False)
71
+
72
+
73
+ @click.group(invoke_without_command=True)
74
+ @click.option("--watch", is_flag=True, help="Live-refreshing view, like top.")
75
+ @click.option(
76
+ "--interval",
77
+ default=DEFAULT_INTERVAL,
78
+ show_default=True,
79
+ help="Refresh interval in seconds for --watch.",
80
+ )
81
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
82
+ @click.option(
83
+ "--provider",
84
+ "provider_ids",
85
+ multiple=True,
86
+ help="Limit to one or more provider ids (repeatable), e.g. --provider claude-code.",
87
+ )
88
+ @click.option("-v", "--verbose", is_flag=True, help="Report which data source answered per provider.")
89
+ @click.option(
90
+ "-a",
91
+ "--all",
92
+ "show_all",
93
+ is_flag=True,
94
+ help="Include providers that aren't signed in (hidden by default).",
95
+ )
96
+ @click.option(
97
+ "--compact",
98
+ is_flag=True,
99
+ help="Single line, statusline-style: glyph + worst % per provider, no labels or reset times.",
100
+ )
101
+ @click.pass_context
102
+ def main(
103
+ ctx: click.Context,
104
+ watch: bool,
105
+ interval: int,
106
+ as_json: bool,
107
+ provider_ids: tuple[str, ...],
108
+ verbose: bool,
109
+ show_all: bool,
110
+ compact: bool,
111
+ ) -> None:
112
+ """Quota status for Claude Code, Cursor, Codex, Antigravity, and Grok.
113
+
114
+ Providers that have never been signed in are hidden by default; pass
115
+ -a/--all to see them too.
116
+ """
117
+ if ctx.invoked_subcommand is not None:
118
+ return
119
+
120
+ providers = build_providers()
121
+ explicit_ids: set[str] = set(provider_ids)
122
+ if explicit_ids:
123
+ known = {p.id for p in providers}
124
+ unknown = explicit_ids - known
125
+ if unknown:
126
+ raise click.UsageError(
127
+ f"Unknown provider id(s): {', '.join(sorted(unknown))}. "
128
+ f"Known: {', '.join(sorted(known))}."
129
+ )
130
+ providers = [p for p in providers if p.id in explicit_ids]
131
+
132
+ store = StateStore()
133
+
134
+ if as_json and watch:
135
+ raise click.UsageError("--json and --watch cannot be combined.")
136
+ if verbose and watch:
137
+ raise click.UsageError("--verbose and --watch cannot be combined.")
138
+ if as_json and compact:
139
+ raise click.UsageError("--json and --compact cannot be combined.")
140
+ if verbose and compact:
141
+ raise click.UsageError("--verbose and --compact cannot be combined.")
142
+
143
+ if as_json:
144
+ rows = _visible_rows(gather_rows(providers, store), show_all=show_all, explicit_ids=explicit_ids)
145
+ console.print_json(build_json(rows))
146
+ return
147
+
148
+ if not watch:
149
+ rows = _visible_rows(gather_rows(providers, store), show_all=show_all, explicit_ids=explicit_ids)
150
+ if compact:
151
+ console.print(build_compact(rows), highlight=False)
152
+ else:
153
+ console.print(build_table(rows))
154
+ if verbose:
155
+ _print_verbose_tiers(providers)
156
+ return
157
+
158
+ with Live(console=console, refresh_per_second=1) as live:
159
+ try:
160
+ while True:
161
+ rows = _visible_rows(
162
+ gather_rows(providers, store), show_all=show_all, explicit_ids=explicit_ids
163
+ )
164
+ live.update(build_compact(rows) if compact else build_table(rows))
165
+ time.sleep(interval)
166
+ except KeyboardInterrupt:
167
+ pass
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
@@ -0,0 +1,18 @@
1
+ """Shared httpx.Client factory. Each provider call gets a short-lived client
2
+ (`with make_client() as c:`) rather than a shared pool — call volume is low
3
+ enough that connection reuse isn't worth the lifecycle management, especially
4
+ across --watch ticks that can span many minutes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+
11
+ DEFAULT_TIMEOUT = 15.0
12
+ USER_AGENT = "quotacli/0.1"
13
+
14
+
15
+ def make_client(*, timeout: float = DEFAULT_TIMEOUT, verify: bool = True) -> httpx.Client:
16
+ """verify=False must only ever be used for Antigravity's self-signed
17
+ loopback bridge call — never for a real internet-facing endpoint."""
18
+ return httpx.Client(timeout=timeout, verify=verify, headers={"User-Agent": USER_AGENT})
@@ -0,0 +1,71 @@
1
+ """macOS Keychain access via pyobjc's Security.framework binding.
2
+
3
+ Deliberately not the `security` CLI or the `keyring` package: both return a
4
+ single item chosen by an undocumented tie-break and have no way to enumerate
5
+ duplicates and pick the newest by modification date. That enumeration is
6
+ required for Claude Code, which writes a brand-new keychain item on every
7
+ token rotation instead of updating one in place.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from Security import (
13
+ SecItemCopyMatching,
14
+ kSecAttrAccount,
15
+ kSecAttrModificationDate,
16
+ kSecAttrService,
17
+ kSecClass,
18
+ kSecClassGenericPassword,
19
+ kSecMatchLimit,
20
+ kSecMatchLimitAll,
21
+ kSecMatchLimitOne,
22
+ kSecReturnAttributes,
23
+ kSecReturnData,
24
+ kSecReturnRef,
25
+ kSecValueRef,
26
+ errSecItemNotFound,
27
+ errSecSuccess,
28
+ )
29
+
30
+
31
+ class KeychainError(Exception):
32
+ """No matching keychain item exists at all. Callers should translate
33
+ this into ErrorKind.NEEDS_AUTH."""
34
+
35
+
36
+ def read_newest_generic_password(service: str, account: str | None = None) -> bytes:
37
+ """Find all generic-password items matching service(+account), pick the
38
+ one with the max kSecAttrModificationDate, then fetch its secret data via
39
+ a single targeted lookup by persistent ref.
40
+ """
41
+ query = {
42
+ kSecClass: kSecClassGenericPassword,
43
+ kSecAttrService: service,
44
+ kSecMatchLimit: kSecMatchLimitAll,
45
+ kSecReturnAttributes: True,
46
+ kSecReturnRef: True,
47
+ }
48
+ if account is not None:
49
+ query[kSecAttrAccount] = account
50
+
51
+ status, results = SecItemCopyMatching(query, None)
52
+ if status == errSecItemNotFound:
53
+ raise KeychainError(f"no keychain item for service={service!r}")
54
+ if status != errSecSuccess:
55
+ raise KeychainError(f"SecItemCopyMatching attrs failed: OSStatus {status}")
56
+
57
+ # kSecMatchLimitAll always returns an NSArray (even for a single match) —
58
+ # it's already iterable and works directly with max(), no wrapping needed.
59
+ newest = max(results, key=lambda item: item[kSecAttrModificationDate])
60
+ value_ref = newest[kSecValueRef]
61
+
62
+ fetch_query = {
63
+ kSecClass: kSecClassGenericPassword,
64
+ kSecValueRef: value_ref,
65
+ kSecMatchLimit: kSecMatchLimitOne,
66
+ kSecReturnData: True,
67
+ }
68
+ status, data = SecItemCopyMatching(fetch_query, None)
69
+ if status != errSecSuccess:
70
+ raise KeychainError(f"SecItemCopyMatching data failed: OSStatus {status}")
71
+ return bytes(data)
@@ -0,0 +1,60 @@
1
+ """Shared provider interface: the contract every provider module implements.
2
+
3
+ An ABC (not typing.Protocol) is used deliberately — there are five known,
4
+ hand-written implementations, not third-party plugins, so nominal inheritance
5
+ is the more honest fit and lets the base class carry shared helpers later.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import abc
11
+ import dataclasses
12
+ import datetime as dt
13
+ import enum
14
+
15
+
16
+ class ErrorKind(enum.Enum):
17
+ NEEDS_AUTH = "needs_auth" # never signed in / no credential found at all
18
+ CREDENTIAL_EXPIRED = "credential_expired" # was valid; provider will self-refresh
19
+ RATE_LIMITED = "rate_limited" # 429, backoff in effect
20
+ ACCESS_DENIED = "access_denied" # authenticated but server said no
21
+ NOTHING_METERED = "nothing_metered" # valid account, nothing to show yet
22
+ BAD_RESPONSE = "bad_response" # network error, timeout, unexpected shape
23
+
24
+
25
+ @dataclasses.dataclass(frozen=True, slots=True)
26
+ class ProviderError(Exception):
27
+ kind: ErrorKind
28
+ message: str
29
+ retry_after: dt.datetime | None = None
30
+
31
+ def __str__(self) -> str: # pragma: no cover - trivial
32
+ return self.message
33
+
34
+
35
+ @dataclasses.dataclass(frozen=True, slots=True)
36
+ class LimitWindow:
37
+ label: str
38
+ used_fraction: float | None
39
+ resets_at: dt.datetime | None
40
+ detail: str | None = None
41
+
42
+
43
+ @dataclasses.dataclass(frozen=True, slots=True)
44
+ class Snapshot:
45
+ provider_id: str
46
+ fetched_at: dt.datetime
47
+ windows: list[LimitWindow]
48
+ plan_label: str | None = None
49
+ is_stale: bool = False
50
+
51
+
52
+ class Provider(abc.ABC):
53
+ id: str
54
+ display_name: str
55
+
56
+ @abc.abstractmethod
57
+ def fetch_snapshot(self) -> Snapshot:
58
+ """Fetch live usage. Raises ProviderError on any failure; never
59
+ returns a partially-valid Snapshot to signal failure."""
60
+ raise NotImplementedError
@@ -0,0 +1,21 @@
1
+ """Provider registry, in canonical display order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from quotacli.protocol import Provider
6
+ from quotacli.providers.antigravity import AntigravityProvider
7
+ from quotacli.providers.claude_code import ClaudeCodeProvider
8
+ from quotacli.providers.codex import CodexProvider
9
+ from quotacli.providers.cursor import CursorProvider
10
+ from quotacli.providers.grok import GrokProvider
11
+
12
+
13
+ def build_providers() -> list[Provider]:
14
+ """Instantiate all five providers, in canonical display order."""
15
+ return [
16
+ ClaudeCodeProvider(),
17
+ GrokProvider(),
18
+ CodexProvider(),
19
+ CursorProvider(),
20
+ AntigravityProvider(),
21
+ ]