clanker-usage 0.4.0__py3-none-any.whl

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 (36) hide show
  1. clanker_usage/__init__.py +5 -0
  2. clanker_usage/__main__.py +3 -0
  3. clanker_usage/cache.py +58 -0
  4. clanker_usage/cli.py +400 -0
  5. clanker_usage/errors.py +10 -0
  6. clanker_usage/http.py +68 -0
  7. clanker_usage/oauth.py +29 -0
  8. clanker_usage/paths.py +38 -0
  9. clanker_usage/providers/__init__.py +13 -0
  10. clanker_usage/providers/agy.py +189 -0
  11. clanker_usage/providers/claude.py +236 -0
  12. clanker_usage/providers/codex.py +243 -0
  13. clanker_usage/providers/cursor.py +122 -0
  14. clanker_usage/providers/deepseek.py +409 -0
  15. clanker_usage/providers/devin.py +119 -0
  16. clanker_usage/providers/gemini.py +192 -0
  17. clanker_usage/providers/google.py +63 -0
  18. clanker_usage/providers/opencode.py +96 -0
  19. clanker_usage/providers/zcode.py +117 -0
  20. clanker_usage/shapes/agy/agy-2026-10-19.json +59 -0
  21. clanker_usage/shapes/claude/claude-2026-10-19.json +54 -0
  22. clanker_usage/shapes/codex/api-2026-10-19.json +37 -0
  23. clanker_usage/shapes/codex/app-server-2026-10-19.json +56 -0
  24. clanker_usage/shapes/cursor/cursor-2026-10-19.json +35 -0
  25. clanker_usage/shapes/deepseek/deepseek-2026-10-19.json +11 -0
  26. clanker_usage/shapes/devin/devin-2026-10-19.json +17 -0
  27. clanker_usage/shapes/gemini/from-gemini-cli-0.56-source.json +12 -0
  28. clanker_usage/shapes/opencode/opencode-2026-10-19.json +30 -0
  29. clanker_usage/shapes/zcode/no-plan-2026-10-18.json +5 -0
  30. clanker_usage/shapes/zcode/quota-2026-09-05.json +29 -0
  31. clanker_usage/shapes.py +88 -0
  32. clanker_usage-0.4.0.dist-info/METADATA +228 -0
  33. clanker_usage-0.4.0.dist-info/RECORD +36 -0
  34. clanker_usage-0.4.0.dist-info/WHEEL +4 -0
  35. clanker_usage-0.4.0.dist-info/entry_points.txt +2 -0
  36. clanker_usage-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Plan and quota of coding-agent accounts."""
2
+
3
+ from .cli import main, run
4
+
5
+ __all__ = ["main", "run"]
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ raise SystemExit(main())
clanker_usage/cache.py ADDED
@@ -0,0 +1,58 @@
1
+ """Private, atomically written JSON files for caches and check state."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ LIMIT = 1 << 20
10
+
11
+
12
+ def read_json(path):
13
+ """The object stored at path, or None when it is missing, oversized or malformed."""
14
+ try:
15
+ with open(path, "rb") as stream:
16
+ raw = stream.read(LIMIT + 1)
17
+ data = json.loads(raw) if len(raw) <= LIMIT else None
18
+ except (OSError, ValueError):
19
+ return None
20
+ return data if isinstance(data, dict) else None
21
+
22
+
23
+ def write_json(path, data):
24
+ """Replace path with data through a private temporary file in the same directory."""
25
+ path = Path(path)
26
+ # mkdir(parents=True) gives only the last directory the mode, so create each missing one privately.
27
+ for folder in reversed([path.parent, *path.parent.parents]):
28
+ if not folder.exists():
29
+ # Another writer, such as a parallel provider in the all-accounts view, may create it first.
30
+ folder.mkdir(mode=0o700, exist_ok=True)
31
+ fd, name = tempfile.mkstemp(prefix=".usage-", dir=path.parent)
32
+ try:
33
+ with os.fdopen(fd, "w") as stream:
34
+ json.dump(data, stream, indent=2)
35
+ stream.write("\n")
36
+ stream.flush()
37
+ os.fsync(stream.fileno())
38
+ os.replace(name, path)
39
+ finally:
40
+ Path(name).unlink(missing_ok=True)
41
+
42
+
43
+ def age(record, key="retrieved_at"):
44
+ """Seconds since the record's time under key, or None when it is missing or malformed."""
45
+ try:
46
+ updated = datetime.fromisoformat(record[key])
47
+ return (datetime.now(timezone.utc) - updated).total_seconds()
48
+ except (KeyError, TypeError, ValueError):
49
+ return None
50
+
51
+
52
+ def is_fresh(record, max_age):
53
+ return is_fresh_at(record, "retrieved_at", max_age)
54
+
55
+
56
+ def is_fresh_at(record, key, max_age):
57
+ seconds = age(record, key)
58
+ return seconds is not None and 0 <= seconds < max_age
clanker_usage/cli.py ADDED
@@ -0,0 +1,400 @@
1
+ """Show the plan and quota of coding-agent accounts, read from each provider's own API."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import select
8
+ import signal
9
+ import sys
10
+ import time
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from datetime import datetime, timezone
13
+ from importlib.metadata import PackageNotFoundError, version
14
+ from pathlib import Path
15
+
16
+ from . import cache, paths, shapes
17
+ from .errors import ExpectedUnavailable
18
+ from .providers import PROVIDERS
19
+
20
+ CORE = "clanker-usage"
21
+ SCHEMA = "usage-v1"
22
+ ERRORS = (RuntimeError, ValueError, OSError, OverflowError)
23
+ FETCH_ERRORS = (RuntimeError, ValueError, OverflowError)
24
+ STATUSLINE_MAX_AGE = 300
25
+
26
+
27
+ def package_version(package):
28
+ try:
29
+ return version(package)
30
+ except PackageNotFoundError:
31
+ return version(CORE)
32
+
33
+
34
+ def now():
35
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
36
+
37
+
38
+ def document(provider, account, status, body=None, error=None, retrieved_at=None):
39
+ """A usage-v1 document: the same fields for every provider, with provider-only data under details."""
40
+ body = body or {"plan": None, "windows": [], "details": {}}
41
+ return {
42
+ "schema": SCHEMA, "provider": provider.NAME, "account": account, "plan": body["plan"],
43
+ "status": status, "error": error, "retrieved_at": retrieved_at, "windows": body["windows"],
44
+ "balance": body.get("balance"), "details": body["details"],
45
+ }
46
+
47
+
48
+ def cached(provider, account):
49
+ record = cache.read_json(paths.cache_file(provider.NAME, account))
50
+ if record and record.get("schema") == SCHEMA and record.get("account") == account:
51
+ return record
52
+ return None
53
+
54
+
55
+ def fetch(provider, account):
56
+ """Fetch live, save the document in the account's cache, and return it."""
57
+ record = document(provider, account, "live", provider.normalize(provider.fetch()), retrieved_at=now())
58
+ cache.write_json(paths.cache_file(provider.NAME, account), record)
59
+ return record
60
+
61
+
62
+ def unavailable(provider, account, error):
63
+ """Save that the account has no quota to read, replacing its old windows, so they are not shown as current."""
64
+ record = document(provider, account, "unavailable", error=error, retrieved_at=now())
65
+ cache.write_json(paths.cache_file(provider.NAME, account), record)
66
+ paths.backoff_file(provider.NAME, account).unlink(missing_ok=True)
67
+ return record
68
+
69
+
70
+ def backoff_seconds(failures):
71
+ """How long repeated failures pause fetching: one minute, doubling up to 30 minutes."""
72
+ return min(60 * 2 ** (failures - 1), 1800)
73
+
74
+
75
+ def load(provider, max_age=0):
76
+ """The provider's document: cached when younger than max_age, else live, else stale or unavailable.
77
+
78
+ With max_age above 0, as for a statusline, a recent failure pauses fetching, so repeated
79
+ prompts during an outage do not each retry. max_age 0 always fetches.
80
+ """
81
+ try:
82
+ account = provider.account()
83
+ except FETCH_ERRORS as exc:
84
+ return document(provider, None, "unavailable", error=str(exc))
85
+ previous = cached(provider, account)
86
+ if previous and max_age > 0 and cache.is_fresh(previous, max_age):
87
+ return previous if previous["status"] == "unavailable" else {**previous, "status": "cached"}
88
+ backoff = paths.backoff_file(provider.NAME, account)
89
+ failure = cache.read_json(backoff) or {}
90
+ failures = failure.get("failures") if isinstance(failure.get("failures"), int) else 0
91
+ if max_age > 0 and failures and cache.is_fresh_at(failure, "failed_at", backoff_seconds(failures)):
92
+ error = str(failure.get("error"))
93
+ else:
94
+ try:
95
+ record = fetch(provider, account)
96
+ except ExpectedUnavailable as exc:
97
+ return unavailable(provider, account, str(exc))
98
+ except FETCH_ERRORS as exc:
99
+ error = str(exc)
100
+ cache.write_json(backoff, {"failed_at": now(), "failures": failures + 1, "error": error})
101
+ else:
102
+ backoff.unlink(missing_ok=True)
103
+ return record
104
+ if previous and previous["status"] != "unavailable":
105
+ return {**previous, "status": "stale", "error": error}
106
+ return document(provider, account, "unavailable", error=error)
107
+
108
+
109
+ def short_duration(iso):
110
+ """Time until iso, such as 2.9h or 4.7d; empty once it has passed."""
111
+ hours = (datetime.fromisoformat(iso) - datetime.now(timezone.utc)).total_seconds() / 3600
112
+ if hours <= 0:
113
+ return ""
114
+ return f"{hours / 24:.1f}d" if hours >= 48 else f"{hours:.1f}h"
115
+
116
+
117
+ def pct(value):
118
+ """A share for display, to one decimal place at most; JSON keeps the exact value."""
119
+ return f"{round(value, 1):g}%"
120
+
121
+
122
+ def label(window):
123
+ """The window's name, after its scope when the scope adds something, such as "Gemini weekly"."""
124
+ scope = window.get("scope")
125
+ return f"{scope} {window['name']}" if scope and scope != window["name"] else window["name"]
126
+
127
+
128
+ def money(item):
129
+ return f"{item['amount']:.2f} {item['currency']}"
130
+
131
+
132
+ def status_note(record):
133
+ if record["status"] == "unavailable":
134
+ return f"unavailable: {record['error']}"
135
+ if record["status"] == "stale":
136
+ return f"stale, from {record['retrieved_at']}; live fetch failed: {record['error']}"
137
+ return None
138
+
139
+
140
+ def render(provider, record):
141
+ lines = [f"{provider.LABEL}: {record['plan'] or 'no plan'}"]
142
+ lines += [f" balance: {money(item)}" for item in record.get("balance") or []]
143
+ for window in record["windows"]:
144
+ line = f" {label(window)}: {pct(window['used_pct'])} used"
145
+ if window["resets_at"]:
146
+ reset = datetime.fromisoformat(window["resets_at"]).astimezone()
147
+ line += f", resets {reset:%Y-%m-%d %H:%M %Z}"
148
+ lines.append(line)
149
+ note = status_note(record)
150
+ return "\n".join(lines + ([f" ({note})"] if note else []))
151
+
152
+
153
+ def harness_prefix(status):
154
+ """The directory, model and cost from the status JSON a harness pipes to its statusline, as Claude Code does."""
155
+ if not isinstance(status, dict):
156
+ return ""
157
+ parts = []
158
+ directory = (status.get("workspace") or {}).get("current_dir") if isinstance(status.get("workspace"), dict) else None
159
+ if isinstance(directory, str) and directory:
160
+ home = str(Path.home())
161
+ parts.append("~" + directory[len(home):] if directory == home or directory.startswith(home + "/") else directory)
162
+ model = (status.get("model") or {}).get("display_name") if isinstance(status.get("model"), dict) else None
163
+ if isinstance(model, str) and model:
164
+ parts.append(f"[{model}]")
165
+ cost = (status.get("cost") or {}).get("total_cost_usd") if isinstance(status.get("cost"), dict) else None
166
+ if isinstance(cost, int | float) and not isinstance(cost, bool):
167
+ parts.append(f"${cost:.2f}")
168
+ return " ".join(parts) + " | " if parts else ""
169
+
170
+
171
+ def read_harness_status():
172
+ """The JSON a harness writes to the statusline's stdin, or None when stdin is a terminal or holds none."""
173
+ if sys.stdin is None or sys.stdin.isatty():
174
+ return None
175
+ try:
176
+ if os.name == "posix" and not select.select([sys.stdin], [], [], 0.5)[0]:
177
+ return None
178
+ return json.loads(sys.stdin.read() or "null")
179
+ except (OSError, ValueError):
180
+ return None
181
+
182
+
183
+ def statusline(provider, record):
184
+ if record["status"] == "unavailable":
185
+ return f"{provider.LABEL} unavailable"
186
+ parts = [f"{provider.LABEL} {record['plan'] or 'no plan'}"]
187
+ parts += [money(item) for item in record.get("balance") or []]
188
+ for window in record["windows"]:
189
+ part = f"{label(window)} {pct(window['used_pct'])}"
190
+ until = short_duration(window["resets_at"]) if window["resets_at"] else ""
191
+ if until:
192
+ part += f" ({until})"
193
+ parts.append(part)
194
+ return " · ".join(parts) + (" (stale)" if record["status"] == "stale" else "")
195
+
196
+
197
+ def table(records):
198
+ """One row per window of every provider's document."""
199
+ rows = [("account", "plan", "window", "used", "resets in", "note")]
200
+ for provider, record in records:
201
+ name = provider.LABEL + (f" {record['account'][:8]}" if record["account"] else "")
202
+ note = status_note(record) or ""
203
+ for item in record.get("balance") or []:
204
+ rows.append((name, record["plan"] or "", "balance", money(item), "", note))
205
+ if not record["windows"] and not record.get("balance"):
206
+ rows.append((name, record["plan"] or "", "-", "", "", note or ("no plan" if not record["plan"] else "")))
207
+ for window in record["windows"]:
208
+ rows.append((name, record["plan"] or "", label(window), pct(window["used_pct"]),
209
+ short_duration(window["resets_at"]) if window["resets_at"] else "", note))
210
+ widths = [max(len(row[i]) for row in rows) for i in range(5)]
211
+ return "\n".join((" ".join(cell.ljust(width) for cell, width in zip(row[:5], widths)) + " " + row[5]).rstrip()
212
+ for row in rows)
213
+
214
+
215
+ def check_one(provider, record_dir=None):
216
+ """Fetch live, compare the response's structure with the recorded shapes, and save the outcome.
217
+
218
+ The result is error, new-shape, missing (a window the previous check saw is absent), ok, or
219
+ expected-unavailable when the login has no quota to read, such as Gemini CLI on an API key.
220
+ The saved state holds paths and window names, never values.
221
+ """
222
+ state_path = paths.state_file(provider.NAME)
223
+ previous = cache.read_json(state_path) or {}
224
+ state = {"checked_at": now(), "result": "ok", "details": [], "windows": previous.get("windows", [])}
225
+ try:
226
+ payload = provider.fetch()
227
+ if record_dir:
228
+ path = Path(record_dir) / f"{provider.NAME}-{datetime.now(timezone.utc):%Y-%m-%d}.json"
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ path.write_text(json.dumps(shapes.synthetic(payload, provider.KEEP), indent=2, ensure_ascii=False) + "\n")
231
+ state["details"].append(f"recorded {path}")
232
+ new = sorted(shapes.paths(payload) - shapes.known_paths(provider.NAME))
233
+ if new:
234
+ state.update(result="new-shape")
235
+ state["details"] += new
236
+ names = [window["name"] for window in provider.normalize(payload)["windows"]]
237
+ except ExpectedUnavailable as exc:
238
+ state["result"] = "expected-unavailable"
239
+ state["details"].append(str(exc))
240
+ except ERRORS as exc:
241
+ state["result"] = "error"
242
+ state["details"].append(str(exc))
243
+ else:
244
+ missing = [name for name in state["windows"] if name not in names]
245
+ if missing and state["result"] == "ok":
246
+ state["result"] = "missing"
247
+ state["details"] += [f"missing window: {name}" for name in missing]
248
+ state["windows"] = names
249
+ cache.write_json(state_path, state)
250
+ return state
251
+
252
+
253
+ def check(names, record_dir=None):
254
+ failed = False
255
+ for name in names:
256
+ state = check_one(PROVIDERS[name], record_dir)
257
+ print(f"{name}: {state['result']}" + "".join(f"\n {line}" for line in state["details"]))
258
+ failed |= state["result"] not in ("ok", "expected-unavailable")
259
+ return 1 if failed else 0
260
+
261
+
262
+ def daemon(provider, interval):
263
+ """Refresh the account's cache every interval, skipping a refresh another process just made."""
264
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
265
+ backoff = interval
266
+ while True:
267
+ try:
268
+ account = provider.account()
269
+ previous = cached(provider, account)
270
+ if not (previous and cache.is_fresh(previous, interval)):
271
+ print(statusline(provider, fetch(provider, account)), flush=True)
272
+ backoff = interval
273
+ except ExpectedUnavailable as exc:
274
+ # Such as an account without a subscription, which an hour rarely changes.
275
+ print(statusline(provider, unavailable(provider, account, str(exc))), flush=True)
276
+ backoff = max(interval, 3600)
277
+ except ERRORS as exc:
278
+ print(str(exc), file=sys.stderr, flush=True)
279
+ backoff = min(backoff * 2, max(interval, 3600))
280
+ time.sleep(backoff)
281
+
282
+
283
+ def positive_int(value):
284
+ result = int(value)
285
+ if result <= 0:
286
+ raise argparse.ArgumentTypeError("must be positive")
287
+ return result
288
+
289
+
290
+ def nonnegative_int(value):
291
+ result = int(value)
292
+ if result < 0:
293
+ raise argparse.ArgumentTypeError("must not be negative")
294
+ return result
295
+
296
+
297
+ def add_max_age(parser, default):
298
+ parser.add_argument("--max-age", type=nonnegative_int, default=default, metavar="SECONDS",
299
+ help=f"reuse a cache younger than this; 0 always fetches (default: {default})")
300
+
301
+
302
+ def add_provider_commands(parser, provider):
303
+ sub = parser.add_subparsers(dest="command")
304
+ add_max_age(sub.add_parser("status", help="Show current quotas (the default)"), 0)
305
+ add_max_age(sub.add_parser("json", help="Print the usage-v1 document"), 0)
306
+ add_max_age(sub.add_parser("statusline", help="Print one line for a status bar"), STATUSLINE_MAX_AGE)
307
+ sub.add_parser("refresh", help="Fetch and update the cache; fail if unavailable")
308
+ daemon_parser = sub.add_parser("daemon", help=f"Refresh the cache every {provider.INTERVAL} seconds")
309
+ daemon_parser.add_argument("--interval", type=positive_int, default=provider.INTERVAL, metavar="SECONDS")
310
+ checker = sub.add_parser("check", help="Compare a live response with the recorded response shapes")
311
+ checker.add_argument("--record", metavar="DIR", type=Path, help="also save a synthetic copy of the response in DIR")
312
+ if hasattr(provider, "add_commands"):
313
+ provider.add_commands(sub)
314
+
315
+
316
+ def run_provider(provider, args):
317
+ if hasattr(provider, "run_command") and provider.run_command(args):
318
+ return 0
319
+ command = args.command or "status"
320
+ if command == "daemon":
321
+ daemon(provider, args.interval)
322
+ if command == "check":
323
+ return check([provider.NAME], args.record)
324
+ if command == "refresh":
325
+ account = provider.account()
326
+ fetch(provider, account)
327
+ print(f"Updated {paths.cache_file(provider.NAME, account)}")
328
+ return 0
329
+ # A bare provider command is status with its default; the core's own --max-age is for the all-accounts view.
330
+ record = load(provider, args.max_age if args.command else 0)
331
+ if command == "json":
332
+ print(json.dumps(record, indent=2, ensure_ascii=False))
333
+ elif command == "statusline":
334
+ print(harness_prefix(read_harness_status()) + statusline(provider, record))
335
+ else:
336
+ print(render(provider, record))
337
+ return 1 if record["status"] == "unavailable" else 0
338
+
339
+
340
+ def show_all(max_age, as_json):
341
+ """Every account of every provider, including each config directory a provider was configured with."""
342
+ tasks = [(provider, selector) for provider in PROVIDERS.values()
343
+ for selector in (provider.selectors() if hasattr(provider, "selectors") else [None])]
344
+
345
+ def run(task):
346
+ provider, selector = task
347
+ with provider.using(selector) if selector is not None else contextlib.nullcontext():
348
+ return provider, load(provider, max_age)
349
+
350
+ with ThreadPoolExecutor(len(tasks)) as pool:
351
+ results = list(pool.map(run, tasks))
352
+ seen, rows = set(), []
353
+ for provider, record in results:
354
+ # Two config directories signed into one account show it once.
355
+ key = (provider.NAME, record["account"] or id(record))
356
+ if key not in seen:
357
+ seen.add(key)
358
+ rows.append((provider, record))
359
+ if as_json:
360
+ print(json.dumps([record for _, record in rows], indent=2, ensure_ascii=False))
361
+ else:
362
+ print(table(rows))
363
+ return 0
364
+
365
+
366
+ def guarded(function, *args):
367
+ try:
368
+ return function(*args)
369
+ except ERRORS as exc:
370
+ print(str(exc), file=sys.stderr)
371
+ return 1
372
+ except KeyboardInterrupt:
373
+ return 0
374
+
375
+
376
+ def run(name, argv=None):
377
+ """The command of one provider's package, such as zcode-cli-usage."""
378
+ provider = PROVIDERS[name]
379
+ parser = argparse.ArgumentParser(prog=provider.PACKAGE, description=provider.DESCRIPTION)
380
+ parser.add_argument("--version", action="version", version=package_version(provider.PACKAGE))
381
+ add_provider_commands(parser, provider)
382
+ return guarded(run_provider, provider, parser.parse_args(argv))
383
+
384
+
385
+ def main(argv=None):
386
+ parser = argparse.ArgumentParser(prog=CORE, description=__doc__)
387
+ parser.add_argument("--version", action="version", version=version(CORE))
388
+ parser.add_argument("--json", action="store_true", help="print every account's usage-v1 document")
389
+ add_max_age(parser, STATUSLINE_MAX_AGE)
390
+ sub = parser.add_subparsers(dest="provider", metavar="{check," + ",".join(PROVIDERS) + "}")
391
+ checker = sub.add_parser("check", help="Check every provider's live response against its recorded shapes")
392
+ checker.add_argument("--record", metavar="DIR", type=Path, help="also save synthetic copies of the responses in DIR")
393
+ for name, provider in PROVIDERS.items():
394
+ add_provider_commands(sub.add_parser(name, help=provider.DESCRIPTION), provider)
395
+ args = parser.parse_args(argv)
396
+ if args.provider is None:
397
+ return guarded(show_all, args.max_age, args.json)
398
+ if args.provider == "check":
399
+ return guarded(check, list(PROVIDERS), args.record)
400
+ return guarded(run_provider, PROVIDERS[args.provider], args)
@@ -0,0 +1,10 @@
1
+ """Errors a provider raises that the core treats specially."""
2
+
3
+
4
+ class ExpectedUnavailable(RuntimeError):
5
+ """The account, or the login in use, has no quota to read: Gemini CLI on an API key, or a Claude
6
+ account without a subscription.
7
+
8
+ The document is unavailable and, once the account is known, cached as such, replacing its old
9
+ windows. check reports expected-unavailable instead of an error.
10
+ """
clanker_usage/http.py ADDED
@@ -0,0 +1,68 @@
1
+ """Authenticated JSON requests that never follow redirects and never echo secrets."""
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ LIMIT = 1 << 20
10
+
11
+
12
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
13
+ def redirect_request(self, *args, **kwargs):
14
+ # A redirect surfaces as an HTTPError, so credentials never reach another URL.
15
+ return None
16
+
17
+
18
+ _opener = urllib.request.build_opener(_NoRedirect)
19
+
20
+
21
+ class StatusError(RuntimeError):
22
+ """An HTTP error status, kept as .code so a caller can retry after renewing a token."""
23
+
24
+ def __init__(self, message, code):
25
+ super().__init__(message)
26
+ self.code = code
27
+
28
+
29
+ def user_agent():
30
+ try:
31
+ return f"clanker-usage/{version('clanker-usage')}"
32
+ except PackageNotFoundError:
33
+ return "clanker-usage"
34
+
35
+
36
+ def request_json(url, headers, body=None, form=None, timeout=15):
37
+ """GET url, or POST body as JSON or form as a URL-encoded form, and return the decoded response.
38
+
39
+ Errors name only the status or failure kind, never a header, body or credential.
40
+ """
41
+ headers = {"User-Agent": user_agent(), "Accept": "application/json", **headers}
42
+ if not all(value.isprintable() for value in headers.values()):
43
+ raise RuntimeError("Invalid credential: contains control characters")
44
+ data = None
45
+ if body is not None:
46
+ data = json.dumps(body, separators=(",", ":")).encode()
47
+ headers.setdefault("Content-Type", "application/json")
48
+ elif form is not None:
49
+ data = urllib.parse.urlencode(form).encode()
50
+ headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
51
+ request = urllib.request.Request(url, data=data, headers=headers, method="POST" if data is not None else "GET")
52
+ try:
53
+ with _opener.open(request, timeout=timeout) as response:
54
+ raw = response.read(LIMIT + 1)
55
+ except urllib.error.HTTPError as exc:
56
+ # Never include response bodies or request headers in diagnostics.
57
+ if exc.code in (401, 403):
58
+ raise StatusError(f"Credential rejected (HTTP {exc.code}); sign in again with the provider's own client",
59
+ exc.code) from None
60
+ raise StatusError(f"Usage request failed (HTTP {exc.code})", exc.code) from None
61
+ except (urllib.error.URLError, TimeoutError):
62
+ raise RuntimeError("Could not connect to the usage API") from None
63
+ if len(raw) > LIMIT:
64
+ raise RuntimeError("Usage response exceeds 1 MiB")
65
+ try:
66
+ return json.loads(raw)
67
+ except ValueError:
68
+ raise ValueError("Usage response is not valid JSON") from None
clanker_usage/oauth.py ADDED
@@ -0,0 +1,29 @@
1
+ """Renewing an OAuth access token that the owning client keeps in a file.
2
+
3
+ A provider renews the token only when the owning client left it expired, and replaces the file
4
+ only if nobody changed it meanwhile, so a renewal the owning client completed is never undone.
5
+ """
6
+
7
+ from . import cache, http
8
+
9
+ GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
10
+
11
+
12
+ def google_refresh(refresh_token, client_id, client_secret):
13
+ """A new access token from Google's token endpoint. A rejected refresh raises http.StatusError."""
14
+ return http.request_json(GOOGLE_TOKEN_URL, {}, form={
15
+ "grant_type": "refresh_token", "refresh_token": refresh_token,
16
+ "client_id": client_id, "client_secret": client_secret,
17
+ })
18
+
19
+
20
+ def replace_if_unchanged(path, expected, identity, updated):
21
+ """Save updated at path unless the file's identity is no longer expected; return what the file now holds.
22
+
23
+ identity maps the file's contents, such as its refresh and access tokens, to something comparable.
24
+ """
25
+ latest = cache.read_json(path)
26
+ if latest is not None and identity(latest) != expected:
27
+ return latest
28
+ cache.write_json(path, updated)
29
+ return updated
clanker_usage/paths.py ADDED
@@ -0,0 +1,38 @@
1
+ """Where clanker-usage keeps its cache, check state and configuration."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from . import cache
7
+
8
+
9
+ def _base(variable, default):
10
+ return Path(os.environ.get(variable) or Path.home() / default) / "clanker-usage"
11
+
12
+
13
+ def cache_file(provider, account):
14
+ return _base("XDG_CACHE_HOME", ".cache") / provider / f"{account}.json"
15
+
16
+
17
+ def state_file(provider):
18
+ return _base("XDG_STATE_HOME", ".local/state") / "check" / f"{provider}.json"
19
+
20
+
21
+ def backoff_file(provider, account):
22
+ return _base("XDG_STATE_HOME", ".local/state") / "backoff" / provider / f"{account}.json"
23
+
24
+
25
+ def config_file():
26
+ return _base("XDG_CONFIG_HOME", ".config") / "config.json"
27
+
28
+
29
+ def config(provider):
30
+ """The provider's section of the configuration file, empty when unset."""
31
+ section = (cache.read_json(config_file()) or {}).get(provider)
32
+ return section if isinstance(section, dict) else {}
33
+
34
+
35
+ def set_config(provider, section):
36
+ data = cache.read_json(config_file()) or {}
37
+ data[provider] = section
38
+ cache.write_json(config_file(), data)
@@ -0,0 +1,13 @@
1
+ """One module per account provider. Each defines the names and functions that cli.py calls:
2
+
3
+ NAME, LABEL, PACKAGE, DESCRIPTION, INTERVAL and KEEP; account(), a stable id or credential
4
+ fingerprint; fetch(), the raw response, reduced to the parts the provider reads; and normalize(),
5
+ which turns that response into the document's plan, windows, details and, for a balance account,
6
+ balance. A provider may add
7
+ add_commands(sub) and run_command(args) for its own setup command, and selectors() with using(selector)
8
+ when one person can have several accounts on one machine, such as Claude's config directories.
9
+ """
10
+
11
+ from . import agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode
12
+
13
+ PROVIDERS = {module.NAME: module for module in (agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode)}