cctally 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,812 @@
1
+ """OAuth-usage refresh: API fetch, UA discovery, renderers, `cctally refresh-usage` entry point.
2
+
3
+ Eager I/O sibling: bin/cctally loads this at startup and re-exports
4
+ every public symbol so bare-name callers (the dashboard
5
+ `POST /api/sync` handler, `cmd_hook_tick`'s oauth-refresh path, the
6
+ record-usage milestone gate, …) all resolve unchanged. Tests reaching
7
+ in via ``ns["X"]`` direct-dict access (extensive — see
8
+ `tests/test_refresh_usage_helpers.py`, `tests/test_refresh_usage_cmd.py`,
9
+ `tests/test_refresh_usage_inproc.py`, `tests/test_oauth_usage_config.py`,
10
+ `tests/test_ua_discovery.py`, `tests/test_hook_tick_rate_limit.py`,
11
+ `tests/test_dashboard_api_sync_refresh.py`) still work because the
12
+ re-export populates cctally's namespace at module-load time.
13
+
14
+ Stays in bin/cctally (reached via the ``_cctally()`` accessor):
15
+ - ``_resolve_oauth_token`` / ``_read_keychain_oauth_blob`` —
16
+ auth-layer primitives also consumed outside refresh.
17
+ - ``_seconds_since_iso``, ``_select_last_known_snapshot``,
18
+ ``_newest_snapshot_age_seconds`` — generic time / DB helpers
19
+ used by dashboard, doctor, and freshness chips.
20
+ - ``cmd_record_usage`` — hot-path record-usage entry (Phase D).
21
+ - ``load_config`` — already in ``_cctally_config.py``; we read it
22
+ through cctally's namespace so test monkeypatches propagate.
23
+ - ``HOOK_TICK_DEFAULT_THROTTLE_SECONDS`` — config-fallback constant.
24
+ - Tiny helpers: ``eprint``, ``now_utc_iso``, ``_iso_to_epoch``,
25
+ ``_normalize_percent``, ``_forecast_color_enabled``,
26
+ ``_format_short_duration``.
27
+
28
+ §5.6 Option C call-site rewrites (call-time lookup so test
29
+ monkeypatches on cctally's namespace propagate into this module):
30
+ - `_discover_cc_version`, `_fetch_oauth_usage`,
31
+ `_bust_statusline_cache`, `_refresh_usage_inproc`,
32
+ `_get_oauth_usage_config` — all monkeypatched in at least one
33
+ test; their internal call sites route through `c.X`.
34
+
35
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import dataclasses
41
+ import datetime as dt
42
+ import json
43
+ import os
44
+ import pathlib
45
+ import re
46
+ import socket
47
+ import subprocess
48
+ import sys
49
+ import urllib.error
50
+ import urllib.request
51
+
52
+
53
+ def _cctally():
54
+ """Resolve the current `cctally` module at call-time (spec §5.5)."""
55
+ return sys.modules["cctally"]
56
+
57
+
58
+ # =========================================================================
59
+ # Exception classes
60
+ # =========================================================================
61
+
62
+ class RefreshUsageNetworkError(Exception):
63
+ """Raised when the OAuth usage API can't be reached or returns non-2xx."""
64
+
65
+
66
+ class RefreshUsageRateLimitError(RefreshUsageNetworkError):
67
+ """Raised when the OAuth usage API returns HTTP 429.
68
+
69
+ Subclass of RefreshUsageNetworkError for backward compatibility:
70
+ callers that already except RefreshUsageNetworkError continue to
71
+ catch this; specific handlers can except RefreshUsageRateLimitError
72
+ first to branch on the rate-limit case.
73
+ """
74
+
75
+
76
+ class RefreshUsageMalformedError(Exception):
77
+ """Raised when the OAuth usage API response is unparseable or missing
78
+ required seven_day fields (utilization or resets_at)."""
79
+
80
+
81
+ # =========================================================================
82
+ # _RefreshUsageResult + URL
83
+ # =========================================================================
84
+
85
+ @dataclasses.dataclass
86
+ class _RefreshUsageResult:
87
+ """Outcome of a single _refresh_usage_inproc() invocation.
88
+
89
+ status enum:
90
+ ok - fetch + record-usage succeeded.
91
+ rate_limited - Anthropic 429 (fallback=True; last-known data still valid).
92
+ no_oauth_token - _resolve_oauth_token returned None.
93
+ fetch_failed - RefreshUsageNetworkError (DNS, connection, non-429 HTTP error).
94
+ parse_failed - RefreshUsageMalformedError or seven_day field extraction raised.
95
+ record_failed - cmd_record_usage returned non-zero or raised.
96
+
97
+ payload (success only): the dict normally passed to
98
+ _serialize_refresh_usage_json / _render_refresh_usage_text by
99
+ cmd_refresh_usage. None on non-ok statuses. Tests examining only
100
+ status/fallback/reason can ignore this field.
101
+
102
+ warnings (success only): non-fatal degradations that occurred during
103
+ the fetch (e.g. unparseable five_hour fields silently dropped). The
104
+ CLI command emits these via eprint; structured callers may surface
105
+ them however they like. Empty list on no warnings.
106
+ """
107
+ status: str
108
+ fallback: bool = False
109
+ reason: "str | None" = None
110
+ payload: "dict | None" = None
111
+ warnings: list = dataclasses.field(default_factory=list)
112
+
113
+
114
+ _OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
115
+
116
+
117
+ # =========================================================================
118
+ # Claude Code version discovery (User-Agent surface)
119
+ # =========================================================================
120
+
121
+ # Strict semver match with optional prerelease. Three numeric parts;
122
+ # 4-part form like "2.1.116.4" is intentionally rejected (the spec
123
+ # requires a valid `claude-code/<semver>` UA only).
124
+ _CC_SEMVER_RE = re.compile(
125
+ r"(?<!\d)(?<!\d\.)(\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?)(?!\.?\d)"
126
+ )
127
+
128
+
129
+ def _parse_cc_semver(s) -> str | None:
130
+ """Extract the first valid `MAJOR.MINOR.PATCH(-prerelease)?` token from
131
+ `s`. Rejects 4-part forms (e.g. "2.1.116.4") and non-numeric prefixes.
132
+ Returns None if no match.
133
+ """
134
+ if not isinstance(s, str) or not s:
135
+ return None
136
+ m = _CC_SEMVER_RE.search(s)
137
+ return m.group(1) if m else None
138
+
139
+
140
+ CLAUDE_CODE_UA_FALLBACK_VERSION = "2.1.116"
141
+
142
+
143
+ def _discover_cc_version() -> str:
144
+ """Discover the active Claude Code version for our `claude-code/<X>` UA.
145
+
146
+ Order: `claude --version` (5s timeout) → highest semver under
147
+ `~/.local/share/claude/versions/` → CLAUDE_CODE_UA_FALLBACK_VERSION.
148
+ Never raises.
149
+ """
150
+ # Tier 1: active executable.
151
+ try:
152
+ proc = subprocess.run(
153
+ ["claude", "--version"],
154
+ capture_output=True, text=True, timeout=5, check=False,
155
+ )
156
+ v = _parse_cc_semver(proc.stdout) if proc.returncode == 0 else None
157
+ if v:
158
+ return v
159
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
160
+ pass
161
+
162
+ # Tier 2: versions/ directory.
163
+ try:
164
+ versions_dir = pathlib.Path.home() / ".local" / "share" / "claude" / "versions"
165
+ if versions_dir.is_dir():
166
+ candidates = []
167
+ for entry in versions_dir.iterdir():
168
+ if not entry.is_dir():
169
+ continue
170
+ v = _parse_cc_semver(entry.name)
171
+ if v:
172
+ candidates.append(v)
173
+ if candidates:
174
+ # Sort by tuple-of-int parts; prerelease tuples sort lower.
175
+ def _key(s):
176
+ base, _, pre = s.partition("-")
177
+ base_parts = tuple(int(x) for x in base.split("."))
178
+ # Prerelease present -> sorts lower than no-prerelease for same base.
179
+ return (base_parts, 0 if pre else 1, pre)
180
+ candidates.sort(key=_key)
181
+ return candidates[-1]
182
+ except OSError:
183
+ pass
184
+
185
+ return CLAUDE_CODE_UA_FALLBACK_VERSION
186
+
187
+
188
+ def _resolve_oauth_usage_user_agent(
189
+ oauth_usage_cfg: dict,
190
+ *,
191
+ version_resolver=None,
192
+ ) -> str:
193
+ """Return the User-Agent string for /api/oauth/usage requests.
194
+
195
+ Honors `oauth_usage_cfg["user_agent"]` override; otherwise builds
196
+ `claude-code/<version>` via `version_resolver` (injectable for tests).
197
+ """
198
+ if version_resolver is None:
199
+ # §5.6 Option C: route through cctally's namespace so
200
+ # `monkeypatch.setitem(ns, "_discover_cc_version", …)` propagates
201
+ # into this caller (tests/test_refresh_usage_helpers.py).
202
+ version_resolver = _cctally()._discover_cc_version
203
+ override = oauth_usage_cfg.get("user_agent")
204
+ if override:
205
+ return override
206
+ return f"claude-code/{version_resolver()}"
207
+
208
+
209
+ # =========================================================================
210
+ # Core OAuth fetch
211
+ # =========================================================================
212
+
213
+ def _fetch_oauth_usage(token: str, timeout_seconds: float) -> dict:
214
+ """GET the OAuth usage API and return the parsed JSON object.
215
+
216
+ Raises ``RefreshUsageNetworkError`` for any network-layer failure
217
+ (connection, DNS, timeout, non-2xx HTTP). Raises
218
+ ``RefreshUsageMalformedError`` when the response body is not JSON
219
+ or is missing the required ``seven_day.utilization`` or
220
+ ``seven_day.resets_at`` fields.
221
+ """
222
+ c = _cctally()
223
+ cfg = c._get_oauth_usage_config(c.load_config())
224
+ user_agent = _resolve_oauth_usage_user_agent(cfg)
225
+ req = urllib.request.Request(
226
+ _OAUTH_USAGE_URL,
227
+ headers={
228
+ "Authorization": f"Bearer {token}",
229
+ "anthropic-beta": "oauth-2025-04-20",
230
+ "Content-Type": "application/json",
231
+ "User-Agent": user_agent,
232
+ },
233
+ )
234
+ try:
235
+ with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
236
+ body = resp.read()
237
+ except urllib.error.HTTPError as e:
238
+ snippet = ""
239
+ try:
240
+ snippet = (e.read() or b"").decode("utf-8", errors="replace")[:200]
241
+ except Exception:
242
+ pass
243
+ msg = f"HTTP {e.code} {e.reason}" + (f": {snippet}" if snippet else "")
244
+ if e.code == 429:
245
+ raise RefreshUsageRateLimitError(msg) from e
246
+ raise RefreshUsageNetworkError(msg) from e
247
+ except urllib.error.URLError as e:
248
+ raise RefreshUsageNetworkError(f"URLError: {e.reason}") from e
249
+ except socket.timeout as e:
250
+ raise RefreshUsageNetworkError(
251
+ f"timed out after {timeout_seconds}s"
252
+ ) from e
253
+ except OSError as e:
254
+ raise RefreshUsageNetworkError(f"OSError: {e}") from e
255
+
256
+ try:
257
+ data = json.loads(body)
258
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
259
+ raise RefreshUsageMalformedError("response was not JSON") from e
260
+
261
+ seven = data.get("seven_day") if isinstance(data, dict) else None
262
+ if not isinstance(seven, dict) or "utilization" not in seven:
263
+ raise RefreshUsageMalformedError("response missing seven_day.utilization")
264
+ resets_at = seven.get("resets_at")
265
+ if not isinstance(resets_at, str) or not resets_at.strip():
266
+ raise RefreshUsageMalformedError("response missing seven_day.resets_at")
267
+
268
+ return data
269
+
270
+
271
+ # =========================================================================
272
+ # Renderers (text + JSON)
273
+ # =========================================================================
274
+
275
+ # ANSI codes for refresh-usage's compact one-liner. Mirrors the
276
+ # yellow/orange/dim palette used by ~/.claude/statusline-command.sh.
277
+ _REFRESH_USAGE_ANSI = {
278
+ "yellow": "\033[33m",
279
+ "orange": "\033[38;5;208m",
280
+ "dim": "\033[2m",
281
+ "reset": "\033[0m",
282
+ }
283
+
284
+
285
+ def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> str:
286
+ """Render the compact one-liner.
287
+
288
+ Format: ``refresh-usage: 7d N% (in Xd Yh) | 5h N% (in Zh) [src:S cache:C]``.
289
+ The ``5h`` segment is omitted entirely when ``payload["five_hour"]`` is None.
290
+ Color codes are emitted only when ``color`` is True.
291
+ """
292
+ c = _cctally()
293
+ a = _REFRESH_USAGE_ANSI if color else {k: "" for k in _REFRESH_USAGE_ANSI}
294
+
295
+ seven = payload["seven_day"]
296
+ seven_pct = seven["used_percent"]
297
+ seven_resets = seven.get("resets_at_epoch")
298
+ if seven_resets is not None:
299
+ seven_ttl = c._format_short_duration(seven_resets - now_epoch)
300
+ seven_seg = (
301
+ f"{a['yellow']}7d {seven_pct:.0f}%{a['reset']}"
302
+ f" {a['orange']}(in {seven_ttl}){a['reset']}"
303
+ )
304
+ else:
305
+ seven_seg = f"{a['yellow']}7d {seven_pct:.0f}%{a['reset']}"
306
+
307
+ five = payload.get("five_hour")
308
+ if five is not None:
309
+ five_pct = five["used_percent"]
310
+ five_resets = five.get("resets_at_epoch")
311
+ if five_resets is not None:
312
+ five_ttl = c._format_short_duration(five_resets - now_epoch)
313
+ five_seg = (
314
+ f" | {a['yellow']}5h {five_pct:.0f}%{a['reset']}"
315
+ f" {a['orange']}(in {five_ttl}){a['reset']}"
316
+ )
317
+ else:
318
+ five_seg = f" | {a['yellow']}5h {five_pct:.0f}%{a['reset']}"
319
+ else:
320
+ five_seg = ""
321
+
322
+ tag = (
323
+ f" {a['dim']}[src:{payload['source']} "
324
+ f"cache:{payload['statusline_cache']}]{a['reset']}"
325
+ )
326
+
327
+ return f"{a['dim']}refresh-usage:{a['reset']} {seven_seg}{five_seg}{tag}"
328
+
329
+
330
+ def _serialize_refresh_usage_json(payload: dict) -> str:
331
+ """Serialize the JSON-mode payload deterministically (sorted keys, 2-space indent)."""
332
+ return json.dumps(payload, indent=2, sort_keys=True)
333
+
334
+
335
+ # =========================================================================
336
+ # OAuth-usage config block (validator + defaults)
337
+ # =========================================================================
338
+
339
+ class OauthUsageConfigError(ValueError):
340
+ """Raised by _get_oauth_usage_config on invalid oauth_usage block."""
341
+
342
+
343
+ _OAUTH_USAGE_DEFAULTS = {
344
+ "user_agent": None,
345
+ "throttle_seconds": 15,
346
+ "fresh_threshold_seconds": 30,
347
+ "stale_after_seconds": 90,
348
+ }
349
+ _OAUTH_USAGE_THROTTLE_MIN = 5
350
+ _OAUTH_USAGE_THROTTLE_MAX = 600
351
+ _OAUTH_USAGE_USER_AGENT_MAX_LEN = 256
352
+
353
+
354
+ def _get_oauth_usage_config(cfg: dict) -> dict:
355
+ """Return the validated, defaults-filled oauth_usage block.
356
+
357
+ Raises OauthUsageConfigError on invalid values. Unknown sub-keys are
358
+ silently ignored to preserve forward compatibility.
359
+ """
360
+ block = cfg.get("oauth_usage") if isinstance(cfg, dict) else None
361
+ if block is None:
362
+ return dict(_OAUTH_USAGE_DEFAULTS)
363
+ if not isinstance(block, dict):
364
+ raise OauthUsageConfigError(
365
+ f"oauth_usage must be an object, got {type(block).__name__}"
366
+ )
367
+
368
+ out = dict(_OAUTH_USAGE_DEFAULTS)
369
+
370
+ if "user_agent" in block and block["user_agent"] is not None:
371
+ ua = block["user_agent"]
372
+ if not isinstance(ua, str) or not ua:
373
+ raise OauthUsageConfigError(
374
+ "oauth_usage.user_agent must be a non-empty string or null"
375
+ )
376
+ if len(ua) > _OAUTH_USAGE_USER_AGENT_MAX_LEN:
377
+ raise OauthUsageConfigError(
378
+ f"oauth_usage.user_agent exceeds {_OAUTH_USAGE_USER_AGENT_MAX_LEN} chars"
379
+ )
380
+ out["user_agent"] = ua
381
+
382
+ for key in ("throttle_seconds", "fresh_threshold_seconds", "stale_after_seconds"):
383
+ if key in block and block[key] is not None:
384
+ v = block[key]
385
+ if not isinstance(v, int) or isinstance(v, bool):
386
+ raise OauthUsageConfigError(
387
+ f"oauth_usage.{key} must be an integer"
388
+ )
389
+ if v < 1:
390
+ raise OauthUsageConfigError(
391
+ f"oauth_usage.{key} must be >= 1"
392
+ )
393
+ out[key] = v
394
+
395
+ t = out["throttle_seconds"]
396
+ if t < _OAUTH_USAGE_THROTTLE_MIN or t > _OAUTH_USAGE_THROTTLE_MAX:
397
+ raise OauthUsageConfigError(
398
+ f"oauth_usage.throttle_seconds must be in [{_OAUTH_USAGE_THROTTLE_MIN}, "
399
+ f"{_OAUTH_USAGE_THROTTLE_MAX}], got {t}"
400
+ )
401
+ if out["fresh_threshold_seconds"] >= out["stale_after_seconds"]:
402
+ raise OauthUsageConfigError(
403
+ "oauth_usage.fresh_threshold_seconds must be < stale_after_seconds"
404
+ )
405
+
406
+ return out
407
+
408
+
409
+ # =========================================================================
410
+ # Statusline cache bust + freshness + rate-limit handler
411
+ # =========================================================================
412
+
413
+ _STATUSLINE_OAUTH_CACHE = "/tmp/claude-statusline-usage-cache.json"
414
+
415
+
416
+ def _bust_statusline_cache(path: str = _STATUSLINE_OAUTH_CACHE) -> str:
417
+ """Best-effort delete of the statusline OAuth cache file.
418
+
419
+ Returns one of: ``"busted"`` (file existed and was removed),
420
+ ``"absent"`` (file did not exist), ``"error"`` (delete failed for
421
+ a non-FileNotFoundError reason — logged via eprint, does NOT raise).
422
+ """
423
+ c = _cctally()
424
+ try:
425
+ os.remove(path)
426
+ return "busted"
427
+ except FileNotFoundError:
428
+ return "absent"
429
+ except OSError as exc:
430
+ c.eprint(f"refresh-usage: cache-bust failed: {exc}")
431
+ return "error"
432
+
433
+
434
+ def _freshness_label(age_seconds: float, oauth_usage_cfg: dict) -> str:
435
+ """Stub — replaced in Task C1 with full three-tier logic."""
436
+ if age_seconds <= oauth_usage_cfg["fresh_threshold_seconds"]:
437
+ return "fresh"
438
+ if age_seconds <= oauth_usage_cfg["stale_after_seconds"]:
439
+ return "aging"
440
+ return "stale"
441
+
442
+
443
+ def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
444
+ """Implements the §3.2(c) fallback contract: serve last-known
445
+ snapshot from DB, exit 0 in all 429 cases."""
446
+ c = _cctally()
447
+ snap = c._select_last_known_snapshot()
448
+ cfg = _get_oauth_usage_config(c.load_config())
449
+ json_mode = bool(getattr(args, "json", False))
450
+ quiet = bool(getattr(args, "quiet", False))
451
+
452
+ if snap is None:
453
+ c.eprint("refresh-usage: rate-limited; no last-known data; "
454
+ "status-line will populate on next CC tick")
455
+ if json_mode:
456
+ print(json.dumps({
457
+ "status": "rate_limited",
458
+ "fallback": None,
459
+ "freshness": None,
460
+ "reason": "no prior snapshot",
461
+ }, sort_keys=True, indent=2))
462
+ return 0
463
+
464
+ captured_iso = snap.pop("captured_at_utc", None)
465
+ age_s = c._seconds_since_iso(captured_iso) if captured_iso else None
466
+ label = (
467
+ _freshness_label(age_s, cfg) if age_s is not None else "stale"
468
+ )
469
+
470
+ c.eprint(f"refresh-usage: rate-limited; using last-known "
471
+ f"(captured {int(age_s) if age_s is not None else '?'}s ago)")
472
+
473
+ if json_mode:
474
+ envelope = {
475
+ "status": "rate_limited",
476
+ "fallback": snap,
477
+ "freshness": {
478
+ "label": label,
479
+ "captured_at": captured_iso,
480
+ "age_seconds": int(age_s) if age_s is not None else None,
481
+ },
482
+ "reason": "user-agent rate-limit gate",
483
+ }
484
+ print(json.dumps(envelope, sort_keys=True, indent=2))
485
+ elif not quiet:
486
+ # Reuse the standard text renderer with the fallback payload.
487
+ color_mode = getattr(args, "color", "auto") or "auto"
488
+ color = c._forecast_color_enabled(color_mode, sys.stdout)
489
+ now_epoch = int(dt.datetime.now(dt.timezone.utc).timestamp())
490
+ print(_render_refresh_usage_text(snap, color=color, now_epoch=now_epoch))
491
+ return 0
492
+
493
+
494
+ # =========================================================================
495
+ # In-process refresh + cmd_refresh_usage
496
+ # =========================================================================
497
+
498
+ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
499
+ """Force-fetch the OAuth usage API and persist via cmd_record_usage.
500
+
501
+ This is the in-process counterpart to ``cmd_refresh_usage`` (force-fetch
502
+ semantics: NO local throttle, busts statusline cache on success). NOT the
503
+ same path as ``_hook_tick_oauth_refresh`` - that one honors
504
+ ``oauth_usage.throttle_seconds`` and would silently skip an explicit user
505
+ chip-click within the throttle window.
506
+
507
+ Returns a structured ``_RefreshUsageResult`` so callers (chiefly
508
+ ``cmd_refresh_usage`` for stdout printing and the dashboard's
509
+ ``POST /api/sync`` handler for JSON envelope construction) can branch on
510
+ a 6-value status enum without parsing free-form stderr.
511
+
512
+ The harness env var ``CCTALLY_TEST_REFRESH_RESULT`` short-circuits to a
513
+ deterministic outcome (``ok``/``rate_limited``/``no_oauth_token``/
514
+ ``fetch_failed``/``parse_failed``/``record_failed``) so bash-level golden
515
+ harnesses can exercise warning paths without faking OAuth on the wire.
516
+ """
517
+ c = _cctally()
518
+ forced = os.environ.get("CCTALLY_TEST_REFRESH_RESULT")
519
+ if forced:
520
+ return _RefreshUsageResult(
521
+ status=forced,
522
+ fallback=(forced == "rate_limited"),
523
+ reason="test stub",
524
+ )
525
+
526
+ token = c._resolve_oauth_token()
527
+ if not token:
528
+ return _RefreshUsageResult(status="no_oauth_token", reason="no token")
529
+
530
+ try:
531
+ api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
532
+ except RefreshUsageRateLimitError as exc:
533
+ return _RefreshUsageResult(status="rate_limited", fallback=True,
534
+ reason=str(exc))
535
+ except RefreshUsageNetworkError as exc:
536
+ return _RefreshUsageResult(status="fetch_failed", reason=str(exc))
537
+ except RefreshUsageMalformedError as exc:
538
+ return _RefreshUsageResult(status="parse_failed", reason=str(exc))
539
+ except OauthUsageConfigError as exc:
540
+ return _RefreshUsageResult(
541
+ status="fetch_failed",
542
+ reason=f"invalid oauth_usage config: {exc}",
543
+ )
544
+
545
+ seven = api.get("seven_day") or {}
546
+ try:
547
+ # Normalize at the OAuth ingress so the payload JSON published
548
+ # on the SSE envelope (`used_percent` field) is clean even when
549
+ # this code path doesn't reach cmd_record_usage (e.g. payload
550
+ # built then a downstream cmd_record_usage call fails).
551
+ seven_pct = c._normalize_percent(float(seven["utilization"]))
552
+ seven_resets_iso = seven["resets_at"]
553
+ seven_resets_epoch = c._iso_to_epoch(seven_resets_iso)
554
+ except (TypeError, ValueError, KeyError) as exc:
555
+ return _RefreshUsageResult(
556
+ status="parse_failed",
557
+ reason=f"OAuth response had unparseable seven_day fields: {exc}",
558
+ )
559
+
560
+ five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
561
+ five_pct = None
562
+ five_resets_iso = None
563
+ five_resets_epoch = None
564
+ warnings: list = []
565
+ if five is not None and "utilization" in five and "resets_at" in five:
566
+ try:
567
+ five_pct = c._normalize_percent(float(five["utilization"]))
568
+ five_resets_iso = five["resets_at"]
569
+ five_resets_epoch = c._iso_to_epoch(five_resets_iso)
570
+ except (TypeError, ValueError) as exc:
571
+ # 5h is optional - silently degrade rather than fail the command
572
+ # (parity with the previous cmd_refresh_usage behavior; the eprint
573
+ # warning is emitted by cmd_refresh_usage when consuming
574
+ # result.warnings, so /api/sync callers don't get stderr noise).
575
+ five_pct = None
576
+ five_resets_iso = None
577
+ five_resets_epoch = None
578
+ warnings.append(
579
+ f"ignoring unparseable five_hour fields: {exc}"
580
+ )
581
+
582
+ record_args = argparse.Namespace(
583
+ percent=seven_pct,
584
+ resets_at=str(seven_resets_epoch),
585
+ five_hour_percent=five_pct,
586
+ five_hour_resets_at=(
587
+ str(five_resets_epoch) if five_resets_epoch is not None else None
588
+ ),
589
+ )
590
+ try:
591
+ rc = c.cmd_record_usage(record_args)
592
+ except Exception as exc:
593
+ return _RefreshUsageResult(status="record_failed", reason=str(exc))
594
+ if rc != 0:
595
+ return _RefreshUsageResult(status="record_failed", reason=f"exit {rc}")
596
+
597
+ # §5.6 Option C: route through cctally's namespace so
598
+ # `monkeypatch.setitem(ns, "_bust_statusline_cache", …)` propagates
599
+ # (tests/test_refresh_usage_cmd.py:55, test_refresh_usage_inproc.py:18).
600
+ cache_state = c._bust_statusline_cache()
601
+
602
+ fetched_at = c.now_utc_iso()
603
+ fresh_envelope = {
604
+ "label": "fresh",
605
+ "captured_at": fetched_at,
606
+ "age_seconds": 0,
607
+ }
608
+ payload = {
609
+ "schema_version": 1,
610
+ "fetched_at": fetched_at,
611
+ "seven_day": {
612
+ "used_percent": seven_pct,
613
+ "resets_at": seven_resets_iso,
614
+ "resets_at_epoch": seven_resets_epoch,
615
+ },
616
+ "five_hour": (
617
+ {
618
+ "used_percent": five_pct,
619
+ "resets_at": five_resets_iso,
620
+ "resets_at_epoch": five_resets_epoch,
621
+ }
622
+ if five_pct is not None
623
+ else None
624
+ ),
625
+ "freshness": fresh_envelope,
626
+ "source": "api",
627
+ "statusline_cache": cache_state,
628
+ }
629
+ return _RefreshUsageResult(status="ok", payload=payload, warnings=warnings)
630
+
631
+
632
+ def cmd_refresh_usage(args: argparse.Namespace) -> int:
633
+ """Force-fetch the OAuth usage API and persist via cmd_record_usage.
634
+
635
+ Returns: 0 success OR rate-limited (graceful fallback), 2 token missing,
636
+ 3 network/HTTP non-429 failure, 4 malformed response, 5 cmd_record_usage
637
+ internal failure.
638
+
639
+ Thin shell over ``_refresh_usage_inproc``: dispatches the structured
640
+ result to the appropriate stderr/stdout renderer while preserving every
641
+ pre-refactor exit code and user-facing message.
642
+ """
643
+ c = _cctally()
644
+ timeout = float(getattr(args, "timeout", 5.0) or 5.0)
645
+ # §5.6 Option C: route through cctally's namespace so
646
+ # `monkeypatch.setitem(ns, "_refresh_usage_inproc", _spy)` propagates
647
+ # (tests/test_dashboard_api_sync_refresh.py:199).
648
+ result = c._refresh_usage_inproc(timeout_seconds=timeout)
649
+
650
+ if result.status == "ok":
651
+ # Surface non-fatal degradations (e.g. dropped five_hour fields).
652
+ # Emit BEFORE rendering so stderr flushes consistently for harnesses
653
+ # that grep across both streams.
654
+ for warning in result.warnings:
655
+ c.eprint(f"refresh-usage: {warning}")
656
+ payload = result.payload or {}
657
+ if getattr(args, "json", False):
658
+ print(_serialize_refresh_usage_json(payload))
659
+ elif not getattr(args, "quiet", False):
660
+ color_mode = getattr(args, "color", "auto") or "auto"
661
+ color = c._forecast_color_enabled(color_mode, sys.stdout)
662
+ now_epoch = int(dt.datetime.now(dt.timezone.utc).timestamp())
663
+ print(_render_refresh_usage_text(payload, color=color, now_epoch=now_epoch))
664
+ return 0
665
+
666
+ if result.status == "rate_limited":
667
+ return _cmd_refresh_usage_handle_rate_limit(
668
+ args, RefreshUsageRateLimitError(result.reason or "rate limited"))
669
+
670
+ if result.status == "no_oauth_token":
671
+ c.eprint("refresh-usage: no OAuth token found "
672
+ "(run 'claude' once to authenticate)")
673
+ return 2
674
+
675
+ if result.status == "fetch_failed":
676
+ # Distinguish the OauthUsageConfigError sub-case (returns 2) from
677
+ # genuine network/HTTP failures (returns 3). The helper carries the
678
+ # config-error reason string verbatim so we can detect it here.
679
+ reason = result.reason or ""
680
+ if reason.startswith("invalid oauth_usage config:"):
681
+ c.eprint(f"cctally: {reason}")
682
+ return 2
683
+ c.eprint(f"refresh-usage: OAuth fetch failed: {reason}")
684
+ return 3
685
+
686
+ if result.status == "parse_failed":
687
+ # Five-hour parse failures are silently degraded inside the helper;
688
+ # only seven_day failures (RefreshUsageMalformedError or unparseable
689
+ # field extraction) propagate here. Preserve the original exact
690
+ # error-line shape for bash harnesses that grep stderr.
691
+ c.eprint(f"refresh-usage: {result.reason or ''}")
692
+ return 4
693
+
694
+ if result.status == "record_failed":
695
+ reason = result.reason or ""
696
+ if reason.startswith("exit "):
697
+ try:
698
+ rc = int(reason.split()[1])
699
+ except (IndexError, ValueError):
700
+ rc = -1
701
+ c.eprint(f"refresh-usage: failed to record usage (exit {rc})")
702
+ else:
703
+ c.eprint(f"refresh-usage: failed to record usage: {reason}")
704
+ return 5
705
+
706
+ # Defensive: unknown status from _refresh_usage_inproc -> treat as parse error.
707
+ c.eprint(f"refresh-usage: unexpected status {result.status!r}")
708
+ return 4
709
+
710
+
711
+ # =========================================================================
712
+ # Hook-tick OAuth refresh path
713
+ # =========================================================================
714
+
715
+ def _hook_tick_oauth_refresh(
716
+ timeout_seconds: float = 5.0,
717
+ throttle_seconds: float | None = None,
718
+ ) -> tuple[str, dict | None]:
719
+ """Run the same OAuth fetch + record-usage path as cmd_refresh_usage,
720
+ BUT do NOT call _bust_statusline_cache().
721
+
722
+ `throttle_seconds` controls the DB-snapshot freshness gate (skip the
723
+ fetch if the newest weekly_usage_snapshots row is younger than this).
724
+ `None` => read `oauth_usage.throttle_seconds` from config (falling back
725
+ to HOOK_TICK_DEFAULT_THROTTLE_SECONDS on validation error). An explicit
726
+ value bypasses config so `cmd_hook_tick`'s already-resolved
727
+ `--throttle-seconds` override (including the `0` escape hatch) reaches
728
+ this gate end-to-end.
729
+
730
+ Returns (status_str, payload_or_none) where status_str is one of:
731
+ "ok(7d=N,5h=M)" | "ok(7d=N)"
732
+ "skipped-no-token" | "skipped(fresh:Ns)"
733
+ "err(network)" | "err(parse)" | "err(record-usage=K)"
734
+ payload_or_none is the raw OAuth-API response dict (`seven_day` /
735
+ `five_hour`) on success, or None on any non-ok branch.
736
+ """
737
+ c = _cctally()
738
+ token = c._resolve_oauth_token()
739
+ if not token:
740
+ return "skipped-no-token", None
741
+ if throttle_seconds is None:
742
+ try:
743
+ throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
744
+ except OauthUsageConfigError:
745
+ throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
746
+ age_s = c._newest_snapshot_age_seconds()
747
+ if age_s is not None and age_s < throttle_seconds:
748
+ return f"skipped(fresh:{int(age_s)}s)", None
749
+ try:
750
+ api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
751
+ except RefreshUsageRateLimitError:
752
+ return "err(rate-limit)", None
753
+ except RefreshUsageNetworkError:
754
+ return "err(network)", None
755
+ except RefreshUsageMalformedError:
756
+ return "err(parse)", None
757
+ seven = api["seven_day"]
758
+ try:
759
+ seven_pct = c._normalize_percent(float(seven["utilization"]))
760
+ seven_resets_epoch = c._iso_to_epoch(seven["resets_at"])
761
+ except (TypeError, ValueError, KeyError):
762
+ return "err(parse)", None
763
+ five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
764
+ five_pct: float | None = None
765
+ five_resets_epoch: int | None = None
766
+ if five is not None and "utilization" in five and "resets_at" in five:
767
+ try:
768
+ five_pct = c._normalize_percent(float(five["utilization"]))
769
+ five_resets_epoch = c._iso_to_epoch(five["resets_at"])
770
+ except (TypeError, ValueError):
771
+ five_pct = None
772
+ five_resets_epoch = None
773
+ record_args = argparse.Namespace(
774
+ percent=seven_pct,
775
+ resets_at=str(seven_resets_epoch),
776
+ five_hour_percent=five_pct,
777
+ five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
778
+ )
779
+ try:
780
+ rc = c.cmd_record_usage(record_args)
781
+ except Exception:
782
+ return "err(record-usage=exc)", None
783
+ if rc != 0:
784
+ return f"err(record-usage={rc})", None
785
+ parts = [f"7d={int(round(seven_pct))}"]
786
+ if five_pct is not None:
787
+ parts.append(f"5h={int(round(five_pct))}")
788
+ return f"ok({','.join(parts)})", api
789
+
790
+
791
+ def _hook_tick_make_mock_refresh(payload_str: str):
792
+ """Return a stand-in for `_hook_tick_oauth_refresh` driven by a fixed
793
+ JSON payload (or sentinel string). Used only by the test harness via
794
+ the hidden `--mock-oauth-response` flag.
795
+ """
796
+ def _mock(timeout_seconds: float = 5.0, throttle_seconds: float | None = None):
797
+ if payload_str == "NETWORK_ERROR":
798
+ return "err(network)", None
799
+ if payload_str == "NO_TOKEN":
800
+ return "skipped-no-token", None
801
+ try:
802
+ data = json.loads(payload_str)
803
+ except ValueError:
804
+ return "err(parse)", None
805
+ seven = data.get("seven_day", {})
806
+ five = data.get("five_hour")
807
+ seven_pct = float(seven.get("utilization", 0))
808
+ parts = [f"7d={int(round(seven_pct))}"]
809
+ if isinstance(five, dict) and "utilization" in five:
810
+ parts.append(f"5h={int(round(float(five['utilization'])))}")
811
+ return f"ok({','.join(parts)})", data
812
+ return _mock