usage-cli 0.29.32__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 (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
adapters/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
adapters/agy.py ADDED
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ import agy_loader as shared_agy_loader
10
+
11
+ from i18n import t
12
+
13
+ from .types import AgentInfo, UsageEntry
14
+
15
+ # Source: tokscale's aliases.rs. Keep unknown Antigravity model IDs unchanged
16
+ # so pricing's existing fallback can handle them.
17
+ _MODEL_ALIASES = {
18
+ "gemini-3-flash-a": "gemini-3-flash-preview",
19
+ "gemini-3-flash-c": "gemini-3-flash-preview",
20
+ "gemini-3-flash": "gemini-3-flash-preview",
21
+ "gemini-3.1-pro-high": "gemini-3.1-pro",
22
+ "gemini-3.1-pro-low": "gemini-3.1-pro",
23
+ "gemini-3-pro-high": "gemini-3-pro",
24
+ "gemini-3-pro-low": "gemini-3-pro",
25
+ }
26
+
27
+ _PROJECT_NAME = "Antigravity"
28
+
29
+
30
+ def detect() -> AgentInfo | None:
31
+ sessions_dir = shared_agy_loader.AGY_SESSIONS_DIR
32
+ if sessions_dir.is_dir():
33
+ return AgentInfo(
34
+ id="antigravity",
35
+ name=t("agy_name"),
36
+ data_dir=str(sessions_dir),
37
+ installed=True,
38
+ )
39
+ return None
40
+
41
+
42
+ def load_entries(hours_back: int = 0) -> list[UsageEntry]:
43
+ result = shared_agy_loader.load_entries_with_stats(hours_back)
44
+ return [_to_usage_entry(entry) for entry in result.entries]
45
+
46
+
47
+ def _to_usage_entry(entry: shared_agy_loader.AgyUsageEntry) -> UsageEntry:
48
+ return UsageEntry(
49
+ timestamp=entry.timestamp,
50
+ session_id=entry.session_id,
51
+ message_id=entry.dedup_key,
52
+ request_id="",
53
+ model=_normalize_model(entry.model),
54
+ input_tokens=entry.input_tokens,
55
+ # Match Codex's existing convention: only its reported output tokens
56
+ # are attributed to UsageEntry.output_tokens; separate reasoning data
57
+ # is not added here.
58
+ output_tokens=entry.output_tokens,
59
+ cache_creation_tokens=0,
60
+ cache_read_tokens=entry.cache_read_tokens,
61
+ cost_usd=None,
62
+ project=_PROJECT_NAME,
63
+ agent_id="antigravity",
64
+ )
65
+
66
+
67
+ def _normalize_model(model: str) -> str:
68
+ return _MODEL_ALIASES.get(model.lower(), model)
adapters/claude.py ADDED
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ import math
8
+ import os
9
+ import sys
10
+ from collections import OrderedDict
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from jsonl_utils import iter_jsonl_dicts
16
+
17
+ from .types import AgentInfo, UsageEntry
18
+
19
+ CLAUDE_DIRS = [
20
+ os.path.expanduser("~/.claude/projects"),
21
+ os.path.expanduser("~/.config/claude/projects"),
22
+ ]
23
+ _FILE_CACHE_MAXSIZE = 512
24
+ _file_cache: OrderedDict[Path, tuple[float, int, list[UsageEntry]]] = OrderedDict()
25
+
26
+
27
+ def detect() -> AgentInfo | None:
28
+ for d in get_claude_dirs():
29
+ if Path(d).is_dir():
30
+ return AgentInfo(
31
+ id="claude-code",
32
+ name="Claude Code",
33
+ data_dir=d,
34
+ installed=True,
35
+ )
36
+ return None
37
+
38
+
39
+ def load_entries(hours_back: int = 0) -> list[UsageEntry]:
40
+ entries: list[UsageEntry] = []
41
+ seen: set[str] = set()
42
+ cutoff = None
43
+ if hours_back > 0:
44
+ from datetime import timedelta
45
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
46
+
47
+ for base_dir in get_claude_dirs():
48
+ base = Path(base_dir)
49
+ if not base.is_dir():
50
+ continue
51
+ for jsonl_path in base.rglob("*.jsonl"):
52
+ fallback_project = extract_project_from_dir(jsonl_path, base)
53
+ parse_jsonl(jsonl_path, fallback_project, entries, seen, cutoff)
54
+
55
+ entries.sort(key=lambda e: e.timestamp)
56
+ return entries
57
+
58
+
59
+ def get_claude_dirs() -> list[str]:
60
+ dirs = list(CLAUDE_DIRS)
61
+ env = os.environ.get("CLAUDE_CONFIG_DIR")
62
+ if env:
63
+ for p in env.split(","):
64
+ projects_dir = os.path.join(p.strip(), "projects")
65
+ if projects_dir not in dirs:
66
+ dirs.insert(0, projects_dir)
67
+ return dirs
68
+
69
+
70
+ def project_from_cwd(cwd: str) -> str:
71
+ home = os.path.expanduser("~")
72
+ rel = cwd[len(home):] if cwd.startswith(home) else cwd
73
+ # Windows sessions read transcripts whose cwd may use either separator
74
+ # (e.g. POSIX-style paths). POSIX filenames may legitimately contain
75
+ # backslashes, so only normalize them on Windows.
76
+ if sys.platform == "win32":
77
+ rel = rel.replace("\\", "/")
78
+ sep = "/"
79
+ else:
80
+ sep = os.sep
81
+ rel = rel.strip(sep)
82
+ parts = rel.split(sep)
83
+ return parts[-1] if parts and parts[-1] else rel or "unknown"
84
+
85
+
86
+ def extract_project_from_dir(jsonl_path: Path, base: Path) -> str:
87
+ rel = jsonl_path.relative_to(base)
88
+ project_dir = str(rel.parts[0]) if rel.parts else "unknown"
89
+ decoded = project_dir.replace("-", os.sep).strip(os.sep)
90
+ home = os.path.expanduser("~").strip(os.sep)
91
+ if decoded.startswith(home):
92
+ decoded = decoded[len(home):].strip(os.sep)
93
+ parts = decoded.split(os.sep)
94
+ return parts[-1] if parts else "unknown"
95
+
96
+
97
+ def parse_jsonl(
98
+ path: Path,
99
+ project: str,
100
+ entries: list[UsageEntry],
101
+ seen: set[str],
102
+ cutoff: datetime | None,
103
+ ) -> None:
104
+ try:
105
+ st = path.stat()
106
+ except (OSError, PermissionError) as exc:
107
+ _debug_file_error("failed to stat Claude log", path, exc)
108
+ return
109
+
110
+ cached = _file_cache.get(path)
111
+ parsed_entries: list[UsageEntry]
112
+ if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size:
113
+ _file_cache.move_to_end(path)
114
+ parsed_entries = cached[2]
115
+ else:
116
+ parsed_entries = []
117
+ try:
118
+ for data in iter_jsonl_dicts(path):
119
+ if data.get("type") != "assistant":
120
+ continue
121
+
122
+ entry = _parse_assistant_entry(data, project)
123
+ if entry is None:
124
+ continue
125
+ parsed_entries.append(entry)
126
+ except (OSError, PermissionError, UnicodeDecodeError) as exc:
127
+ _debug_file_error("failed to read Claude log", path, exc)
128
+ return
129
+
130
+ if path not in _file_cache and len(_file_cache) >= _FILE_CACHE_MAXSIZE:
131
+ _file_cache.popitem(last=False)
132
+ _file_cache[path] = (st.st_mtime, st.st_size, parsed_entries)
133
+
134
+ for entry in parsed_entries:
135
+ if cutoff and entry.timestamp < cutoff:
136
+ continue
137
+
138
+ if entry.dedup_key in seen:
139
+ continue
140
+ seen.add(entry.dedup_key)
141
+
142
+ entries.append(entry)
143
+
144
+
145
+ def _parse_assistant_entry(data: dict[str, Any], project: str) -> UsageEntry | None:
146
+ message = data.get("message")
147
+ if not message or not isinstance(message, dict):
148
+ return None
149
+
150
+ usage = message.get("usage")
151
+ if not usage or not isinstance(usage, dict):
152
+ return None
153
+
154
+ input_tokens = _as_int(usage.get("input_tokens"))
155
+ output_tokens = _as_int(usage.get("output_tokens"))
156
+ cache_creation = _as_int(usage.get("cache_creation_input_tokens"))
157
+ cache_read = _as_int(usage.get("cache_read_input_tokens"))
158
+
159
+ if input_tokens == 0 and output_tokens == 0 and cache_creation == 0 and cache_read == 0:
160
+ return None
161
+
162
+ timestamp_str = data.get("timestamp", "")
163
+ try:
164
+ ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
165
+ except (ValueError, AttributeError):
166
+ return None
167
+
168
+ message_id = message.get("id", "")
169
+ request_id = data.get("requestId") or ""
170
+ model = message.get("model", "unknown")
171
+ session_id = data.get("sessionId", "")
172
+ cost_usd = _as_optional_float(data.get("costUSD"))
173
+
174
+ cwd = data.get("cwd", "")
175
+ if cwd:
176
+ project = project_from_cwd(cwd)
177
+
178
+ return UsageEntry(
179
+ timestamp=ts,
180
+ session_id=session_id,
181
+ message_id=message_id,
182
+ request_id=request_id,
183
+ model=model,
184
+ input_tokens=input_tokens,
185
+ output_tokens=output_tokens,
186
+ cache_creation_tokens=cache_creation,
187
+ cache_read_tokens=cache_read,
188
+ cost_usd=cost_usd,
189
+ project=project,
190
+ agent_id="claude-code",
191
+ )
192
+
193
+
194
+ def _debug_file_error(action: str, path: Path, exc: Exception) -> None:
195
+ if os.environ.get("USAGE_DEBUG") == "1":
196
+ print(f"{action} {path}: {exc}", file=sys.stderr)
197
+
198
+
199
+ def _as_optional_float(value: Any) -> float | None:
200
+ if isinstance(value, bool):
201
+ return None
202
+ try:
203
+ number = float(value)
204
+ except (TypeError, ValueError):
205
+ return None
206
+ if not math.isfinite(number):
207
+ return None
208
+ return number
209
+
210
+
211
+ def _as_int(value: Any) -> int:
212
+ number = _as_optional_float(value)
213
+ if number is None:
214
+ return 0
215
+ return int(number)
adapters/codex.py ADDED
@@ -0,0 +1,209 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ import json
8
+ import math
9
+ import os
10
+ import sqlite3
11
+ import sys
12
+ from collections import OrderedDict, defaultdict
13
+ from contextlib import closing
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import codex_loader as shared_codex_loader
19
+ from codex_paths import codex_home
20
+
21
+ from .types import AgentInfo, RateLimits, UsageEntry
22
+
23
+ CODEX_DIR = str(codex_home())
24
+ SESSIONS_DIR = os.path.join(CODEX_DIR, "sessions")
25
+ STATE_DB = os.path.join(CODEX_DIR, "state_5.sqlite")
26
+ _FILE_CACHE_MAXSIZE = 512
27
+ _file_cache: OrderedDict[Path, tuple[float, int, list[dict[str, Any]]]] = OrderedDict()
28
+
29
+
30
+ def _readonly_sqlite_uri(path: str | Path) -> str:
31
+ return f"{Path(path).resolve().as_uri()}?mode=ro"
32
+
33
+
34
+ def detect() -> AgentInfo | None:
35
+ if Path(SESSIONS_DIR).is_dir():
36
+ return AgentInfo(
37
+ id="codex",
38
+ name="Codex",
39
+ data_dir=SESSIONS_DIR,
40
+ installed=True,
41
+ )
42
+ return None
43
+
44
+
45
+ def load_entries(hours_back: int = 0) -> list[UsageEntry]:
46
+ cutoff = None
47
+ if hours_back > 0:
48
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
49
+
50
+ models = _load_thread_models()
51
+ sessions_path = Path(SESSIONS_DIR)
52
+ delta_entries = shared_codex_loader._load_jsonl_entries(sessions_path, models, cutoff)
53
+ by_session: dict[str, list[Any]] = defaultdict(list)
54
+ for entry in delta_entries:
55
+ by_session[entry.session_id].append(entry)
56
+
57
+ entries = [_aggregate_session(session_entries) for session_entries in by_session.values()]
58
+ entries.sort(key=lambda e: e.timestamp)
59
+ return entries
60
+
61
+
62
+ def _aggregate_session(session_entries: list[Any]) -> UsageEntry:
63
+ first = session_entries[0]
64
+ primary = max(session_entries, key=lambda entry: entry.total_tokens)
65
+ return UsageEntry(
66
+ timestamp=first.timestamp,
67
+ session_id=first.session_id,
68
+ message_id=first.session_id,
69
+ request_id="",
70
+ model=primary.model,
71
+ input_tokens=sum(entry.input_tokens for entry in session_entries),
72
+ output_tokens=sum(entry.output_tokens for entry in session_entries),
73
+ cache_creation_tokens=sum(entry.cache_creation_tokens for entry in session_entries),
74
+ cache_read_tokens=sum(entry.cache_read_tokens for entry in session_entries),
75
+ cost_usd=None,
76
+ project=first.project,
77
+ agent_id="codex",
78
+ message_count=len(session_entries),
79
+ )
80
+
81
+
82
+ def _load_thread_models() -> dict[str, str]:
83
+ if not os.path.exists(STATE_DB):
84
+ return {}
85
+ try:
86
+ with closing(sqlite3.connect(_readonly_sqlite_uri(STATE_DB), uri=True)) as conn:
87
+ rows = conn.execute("SELECT id, model FROM threads WHERE model IS NOT NULL").fetchall()
88
+ return {row[0]: row[1] for row in rows}
89
+ except (sqlite3.Error, OSError) as exc:
90
+ _debug_file_error("failed to load Codex thread models from", Path(STATE_DB), exc)
91
+ return {}
92
+
93
+
94
+ def _extract_rate_limits(path: Path, models: dict[str, str]) -> RateLimits | None:
95
+ session_id = ""
96
+ session_model = "unknown"
97
+ last_rl = None
98
+ try:
99
+ rows = _load_jsonl_rows(path)
100
+ except (OSError, PermissionError, UnicodeDecodeError) as exc:
101
+ _debug_file_error("failed to read Codex session log", path, exc)
102
+ return None
103
+
104
+ for data in rows:
105
+ if data.get("type") == "session_meta":
106
+ payload = data.get("payload", {})
107
+ session_id = payload.get("id", "")
108
+ session_model = _session_model(payload, session_model)
109
+ if data.get("type") == "turn_context":
110
+ session_model = _session_model(data.get("payload"), session_model)
111
+ if data.get("type") != "event_msg":
112
+ continue
113
+ payload = data.get("payload", {})
114
+ if payload.get("type") != "token_count":
115
+ continue
116
+ rl = payload.get("rate_limits")
117
+ if rl:
118
+ last_rl = (rl, data.get("timestamp", ""), session_id)
119
+
120
+ if not last_rl:
121
+ return None
122
+
123
+ rl, ts, sid = last_rl
124
+ primary = rl.get("primary") or {}
125
+ secondary = rl.get("secondary") or {}
126
+
127
+ five_pct = _as_optional_float(primary.get("used_percent"))
128
+ five_reset = _as_optional_int(primary.get("resets_at"))
129
+ seven_pct = _as_optional_float(secondary.get("used_percent"))
130
+ seven_reset = _as_optional_int(secondary.get("resets_at"))
131
+
132
+ now_ts = datetime.now(timezone.utc).timestamp()
133
+ if five_reset is not None and five_reset < now_ts:
134
+ five_pct = 0.0
135
+ five_reset = None
136
+ if seven_reset is not None and seven_reset < now_ts:
137
+ seven_pct = 0.0
138
+ seven_reset = None
139
+
140
+ if five_pct is None and seven_pct is None:
141
+ return None
142
+
143
+ model_name = models.get(sid, "")
144
+
145
+ return RateLimits(
146
+ five_hour_pct=five_pct,
147
+ five_hour_resets_at=five_reset,
148
+ seven_day_pct=seven_pct,
149
+ seven_day_resets_at=seven_reset,
150
+ model=model_name or session_model,
151
+ updated_at=ts,
152
+ )
153
+
154
+
155
+ def _load_jsonl_rows(path: Path) -> list[dict[str, Any]]:
156
+ st = path.stat()
157
+ cached = _file_cache.get(path)
158
+ if cached is not None and cached[0] == st.st_mtime and cached[1] == st.st_size:
159
+ _file_cache.move_to_end(path)
160
+ return cached[2]
161
+
162
+ rows: list[dict[str, Any]] = []
163
+ with open(path, "r", encoding="utf-8") as f:
164
+ for line in f:
165
+ line = line.strip()
166
+ if not line:
167
+ continue
168
+ try:
169
+ data = json.loads(line)
170
+ except json.JSONDecodeError:
171
+ continue
172
+ if isinstance(data, dict):
173
+ rows.append(data)
174
+
175
+ if path not in _file_cache and len(_file_cache) >= _FILE_CACHE_MAXSIZE:
176
+ _file_cache.popitem(last=False)
177
+ _file_cache[path] = (st.st_mtime, st.st_size, rows)
178
+ return rows
179
+
180
+
181
+ def _as_optional_float(value: Any) -> float | None:
182
+ if isinstance(value, bool):
183
+ return None
184
+ try:
185
+ number = float(value)
186
+ except (TypeError, ValueError):
187
+ return None
188
+ if not math.isfinite(number):
189
+ return None
190
+ return number
191
+
192
+
193
+ def _session_model(payload: Any, fallback: str) -> str:
194
+ model = payload.get("model") if isinstance(payload, dict) else ""
195
+ if not isinstance(model, str):
196
+ model = ""
197
+ return model or fallback
198
+
199
+
200
+ def _as_optional_int(value: Any) -> int | None:
201
+ number = _as_optional_float(value)
202
+ if number is None:
203
+ return None
204
+ return int(number)
205
+
206
+
207
+ def _debug_file_error(action: str, path: Path, exc: Exception) -> None:
208
+ if os.environ.get("USAGE_DEBUG") == "1":
209
+ print(f"{action} {path}: {exc}", file=sys.stderr)
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ import json
8
+ import math
9
+ import os
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ from .types import RateLimits
14
+
15
+ STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
16
+ LEGACY_STATUS_FILE = os.path.expanduser("~/.claude/usag-status.json")
17
+ TT_STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
18
+
19
+
20
+ def _as_finite_float(value: Any) -> float | None:
21
+ if isinstance(value, bool):
22
+ return None
23
+ try:
24
+ numeric = float(value)
25
+ except (TypeError, ValueError):
26
+ return None
27
+ return numeric if math.isfinite(numeric) else None
28
+
29
+
30
+ def _read_status() -> dict[str, Any] | None:
31
+ for path in (STATUS_FILE, LEGACY_STATUS_FILE, TT_STATUS_FILE):
32
+ if not os.path.exists(path):
33
+ continue
34
+ try:
35
+ with open(path, "r", encoding="utf-8") as f:
36
+ data: dict[str, Any] = json.load(f)
37
+ return data
38
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
39
+ continue
40
+ return None
41
+
42
+
43
+ def load_rate_limits() -> RateLimits | None:
44
+ data = _read_status()
45
+ if data is None:
46
+ return None
47
+
48
+ rl = data.get("rate_limits") or {}
49
+ five = rl.get("five_hour") or {}
50
+ seven = rl.get("seven_day") or {}
51
+
52
+ now_ts = datetime.now(timezone.utc).timestamp()
53
+ five_pct = _as_finite_float(five.get("used_percentage"))
54
+ five_reset = _as_finite_float(five.get("resets_at"))
55
+ if five_reset and five_reset < now_ts:
56
+ five_pct = 0.0
57
+
58
+ seven_pct = _as_finite_float(seven.get("used_percentage"))
59
+ seven_reset = _as_finite_float(seven.get("resets_at"))
60
+ if seven_reset and seven_reset < now_ts:
61
+ seven_pct = 0.0
62
+
63
+ model_info = data.get("model") or {}
64
+ model_name = model_info.get("display_name") or model_info.get("id") or ""
65
+
66
+ if five_pct is None and seven_pct is None and not model_name:
67
+ return None
68
+
69
+ return RateLimits(
70
+ five_hour_pct=five_pct,
71
+ five_hour_resets_at=int(five_reset) if five_reset is not None else None,
72
+ seven_day_pct=seven_pct,
73
+ seven_day_resets_at=int(seven_reset) if seven_reset is not None else None,
74
+ model=model_name,
75
+ updated_at=data.get("_received_at", ""),
76
+ )
adapters/registry.py ADDED
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from .types import AgentInfo
8
+ from . import agy, claude, codex
9
+
10
+
11
+ def detect_agents() -> list[AgentInfo]:
12
+ agents: list[AgentInfo] = []
13
+ for detector in [claude.detect, codex.detect, agy.detect]:
14
+ info = detector()
15
+ if info:
16
+ agents.append(info)
17
+ return agents