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.
- quotacli_mac-0.1.0/.github/workflows/ci.yml +20 -0
- quotacli_mac-0.1.0/.gitignore +9 -0
- quotacli_mac-0.1.0/LICENSE +21 -0
- quotacli_mac-0.1.0/PKG-INFO +99 -0
- quotacli_mac-0.1.0/README.md +66 -0
- quotacli_mac-0.1.0/pyproject.toml +46 -0
- quotacli_mac-0.1.0/src/quotacli/__init__.py +3 -0
- quotacli_mac-0.1.0/src/quotacli/__main__.py +4 -0
- quotacli_mac-0.1.0/src/quotacli/backoff.py +52 -0
- quotacli_mac-0.1.0/src/quotacli/cli.py +171 -0
- quotacli_mac-0.1.0/src/quotacli/httpclient.py +18 -0
- quotacli_mac-0.1.0/src/quotacli/keychain.py +71 -0
- quotacli_mac-0.1.0/src/quotacli/protocol.py +60 -0
- quotacli_mac-0.1.0/src/quotacli/providers/__init__.py +21 -0
- quotacli_mac-0.1.0/src/quotacli/providers/antigravity.py +339 -0
- quotacli_mac-0.1.0/src/quotacli/providers/claude_code.py +225 -0
- quotacli_mac-0.1.0/src/quotacli/providers/codex.py +193 -0
- quotacli_mac-0.1.0/src/quotacli/providers/cursor.py +175 -0
- quotacli_mac-0.1.0/src/quotacli/providers/grok.py +175 -0
- quotacli_mac-0.1.0/src/quotacli/render.py +205 -0
- quotacli_mac-0.1.0/src/quotacli/state.py +143 -0
- quotacli_mac-0.1.0/tests/__init__.py +0 -0
- quotacli_mac-0.1.0/tests/fixtures/antigravity_quota_summary_bridge.json +44 -0
- quotacli_mac-0.1.0/tests/fixtures/antigravity_quota_summary_fallback.json +11 -0
- quotacli_mac-0.1.0/tests/fixtures/claude_code_usage.json +30 -0
- quotacli_mac-0.1.0/tests/fixtures/claude_code_usage_fallback_merge.json +21 -0
- quotacli_mac-0.1.0/tests/fixtures/codex_usage.json +20 -0
- quotacli_mac-0.1.0/tests/fixtures/codex_usage_30d_free.json +9 -0
- quotacli_mac-0.1.0/tests/fixtures/cursor_usage_summary.json +18 -0
- quotacli_mac-0.1.0/tests/fixtures/grok_billing.json +15 -0
- quotacli_mac-0.1.0/tests/providers/__init__.py +0 -0
- quotacli_mac-0.1.0/tests/providers/test_antigravity_parsing.py +72 -0
- quotacli_mac-0.1.0/tests/providers/test_claude_code_parsing.py +64 -0
- quotacli_mac-0.1.0/tests/providers/test_codex_parsing.py +100 -0
- quotacli_mac-0.1.0/tests/providers/test_cursor_parsing.py +100 -0
- quotacli_mac-0.1.0/tests/providers/test_grok_parsing.py +64 -0
- quotacli_mac-0.1.0/tests/test_backoff.py +65 -0
- quotacli_mac-0.1.0/tests/test_cli.py +82 -0
- quotacli_mac-0.1.0/tests/test_render_compact.py +70 -0
- 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,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,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
|
+
]
|