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
disk_cache_common.py ADDED
@@ -0,0 +1,132 @@
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
+ """Common helpers for sharded JSON disk caches."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import tempfile
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from cache_quarantine import quarantine
21
+
22
+ _SHARD_COUNT = 32
23
+
24
+
25
+ def _serialize_usage_entry(entry: Any) -> dict[str, Any]:
26
+ return {
27
+ "timestamp": entry.timestamp.isoformat(),
28
+ "session_id": entry.session_id,
29
+ "message_id": entry.message_id,
30
+ "request_id": entry.request_id,
31
+ "model": entry.model,
32
+ "input_tokens": entry.input_tokens,
33
+ "output_tokens": entry.output_tokens,
34
+ "cache_creation_tokens": entry.cache_creation_tokens,
35
+ "cache_read_tokens": entry.cache_read_tokens,
36
+ "cost_usd": entry.cost_usd,
37
+ "project": entry.project,
38
+ }
39
+
40
+
41
+ def _deserialize_usage_entry(data: dict[str, Any]) -> Any:
42
+ from history_loader import UsageEntry
43
+
44
+ return UsageEntry(
45
+ timestamp=datetime.fromisoformat(data["timestamp"]),
46
+ session_id=data["session_id"],
47
+ message_id=data["message_id"],
48
+ request_id=data["request_id"],
49
+ model=data["model"],
50
+ input_tokens=data["input_tokens"],
51
+ output_tokens=data["output_tokens"],
52
+ cache_creation_tokens=data["cache_creation_tokens"],
53
+ cache_read_tokens=data["cache_read_tokens"],
54
+ cost_usd=data["cost_usd"],
55
+ project=data["project"],
56
+ )
57
+
58
+
59
+ def _cache_dir(cache_path: Path) -> Path:
60
+ return cache_path.with_suffix(f"{cache_path.suffix}.d")
61
+
62
+
63
+ def _shard_index(path: Path) -> int:
64
+ digest = hashlib.sha256(str(path).encode("utf-8", errors="surrogatepass")).digest()
65
+ return digest[0] % _SHARD_COUNT
66
+
67
+
68
+ def _shard_path(cache_path: Path, index: int) -> Path:
69
+ return _cache_dir(cache_path) / f"files-{index:02x}.json"
70
+
71
+
72
+ def _remove_legacy_cache(cache_path: Path) -> None:
73
+ with contextlib.suppress(OSError):
74
+ cache_path.unlink()
75
+
76
+
77
+ def _load_payload(path: Path, schema_version: int) -> dict[str, Any] | None:
78
+ try:
79
+ with path.open(encoding="utf-8") as file:
80
+ payload = json.load(file)
81
+ if not isinstance(payload, dict):
82
+ quarantine(path, "not-a-mapping")
83
+ path.unlink(missing_ok=True)
84
+ return None
85
+ if payload.get("schema_version") != schema_version:
86
+ path.unlink(missing_ok=True)
87
+ return None
88
+ return payload
89
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
90
+ if isinstance(exc, UnicodeDecodeError):
91
+ quarantine(path, "decode-error")
92
+ else:
93
+ quarantine(path, "json-error")
94
+ with contextlib.suppress(OSError):
95
+ path.unlink()
96
+ return None
97
+ except OSError:
98
+ with contextlib.suppress(OSError):
99
+ path.unlink()
100
+ return None
101
+
102
+
103
+ def _encoded_payload(payload: dict[str, Any]) -> bytes:
104
+ return json.dumps(
105
+ payload,
106
+ ensure_ascii=False,
107
+ separators=(",", ":"),
108
+ sort_keys=True,
109
+ ).encode("utf-8")
110
+
111
+
112
+ def _write_if_changed(path: Path, payload: bytes) -> None:
113
+ try:
114
+ if path.read_bytes() == payload:
115
+ return
116
+ except OSError:
117
+ pass
118
+
119
+ tmp_path: str | None = None
120
+ try:
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
123
+ with os.fdopen(fd, "wb") as file:
124
+ file.write(payload)
125
+ file.flush()
126
+ os.fsync(file.fileno())
127
+ os.replace(tmp_path, path)
128
+ tmp_path = None
129
+ finally:
130
+ if tmp_path:
131
+ with contextlib.suppress(OSError):
132
+ os.unlink(tmp_path)
@@ -0,0 +1,39 @@
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
+ """Shared disk cache lifecycle control flow for usage loaders."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from collections.abc import Callable
13
+
14
+
15
+ def needs_cache_seed(seeded: bool) -> bool:
16
+ return not seeded
17
+
18
+
19
+ def flush_caches_if_due(
20
+ dirty: bool,
21
+ last_flush_at: float | None,
22
+ monotonic: Callable[[], float],
23
+ interval_s: float,
24
+ flush: Callable[[], None],
25
+ *,
26
+ force: bool = False,
27
+ ) -> tuple[bool, float | None]:
28
+ if not dirty:
29
+ return dirty, last_flush_at
30
+ now = monotonic()
31
+ if not force and last_flush_at is not None and now - last_flush_at < interval_s:
32
+ return dirty, last_flush_at
33
+ flush()
34
+ return False, now
35
+
36
+
37
+ def flush_caches_on_terminate(flush: Callable[[], None]) -> None:
38
+ with contextlib.suppress(Exception):
39
+ flush()
doctor.py ADDED
@@ -0,0 +1,452 @@
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 os
10
+ import shlex
11
+ import sqlite3
12
+ import sys
13
+ import tomllib
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime
17
+ from importlib import metadata
18
+ from pathlib import Path
19
+ from typing import Final
20
+
21
+ import setup_hook
22
+ from i18n import packaged_resource_path
23
+
24
+ SEPARATOR = "-" * 29
25
+ RATE_LIMIT_FRESH_SECONDS: Final = 15 * 60
26
+
27
+ STATUS_FILE: Final = "status_file"
28
+ CODEX_SESSIONS: Final = "codex_sessions"
29
+ CODEX_STATE: Final = "codex_state"
30
+ HOOK_STATE: Final = "hook_state"
31
+ HOOK_VERSION: Final = "hook_version"
32
+ HOOK_SCRIPT: Final = "hook_script"
33
+ STATUS_COMMAND: Final = "status_command"
34
+ FORWARDER_SCRIPT: Final = "forwarder_script"
35
+ FORWARDER_PROMPT: Final = "forwarder_prompt"
36
+ EXTERNAL_HOOKS: Final = "external_hooks"
37
+ CODEX_LOGS: Final = "codex_logs"
38
+ CODEX_RATE_LIMITS: Final = "codex_rate_limits"
39
+
40
+ CHECK_LABELS: Final = {
41
+ STATUS_FILE: "status file",
42
+ CODEX_SESSIONS: "codex jsonl",
43
+ CODEX_STATE: "codex state",
44
+ HOOK_STATE: "hook state",
45
+ HOOK_VERSION: "hook version",
46
+ HOOK_SCRIPT: "hook script",
47
+ STATUS_COMMAND: "status command",
48
+ FORWARDER_SCRIPT: "forwarder script",
49
+ FORWARDER_PROMPT: "forwarder prompt",
50
+ EXTERNAL_HOOKS: "external hooks",
51
+ CODEX_LOGS: "codex logs",
52
+ CODEX_RATE_LIMITS: "codex rate limits",
53
+ }
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class CheckResult:
58
+ code: str
59
+ status: str
60
+ detail: str
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class DoctorReport:
65
+ version: str
66
+ checks: list[tuple[str, CheckResult]]
67
+ self_heal_log: list[str]
68
+
69
+
70
+ def collect() -> DoctorReport:
71
+ checks = [
72
+ ("core", _field(STATUS_FILE, _status_file)),
73
+ ("core", _field(CODEX_SESSIONS, _codex_sessions)),
74
+ ("core", _field(CODEX_STATE, _codex_state)),
75
+ ("hook", _field(HOOK_STATE, _hook_state)),
76
+ ("hook", _field(HOOK_VERSION, _hook_version)),
77
+ ("hook", _field(HOOK_SCRIPT, lambda: _script_status(setup_hook.HOOK_TARGET))),
78
+ ("hook", _field(STATUS_COMMAND, _status_command)),
79
+ ("optional", _field(FORWARDER_SCRIPT, _forwarder_script_status)),
80
+ ("optional", _field(FORWARDER_PROMPT, _forwarder_prompt)),
81
+ ("optional", _field(EXTERNAL_HOOKS, _external_hooks)),
82
+ ("optional", _field(CODEX_LOGS, _codex_logs)),
83
+ ("optional", _field(CODEX_RATE_LIMITS, _codex_rate_limits)),
84
+ ]
85
+ return DoctorReport(
86
+ version=_text_field(_current_version),
87
+ checks=checks,
88
+ self_heal_log=_self_heal_log_lines(),
89
+ )
90
+
91
+
92
+ def render(report: DoctorReport | None = None) -> str:
93
+ current = report if report is not None else collect()
94
+ lines = [
95
+ f"usage v{current.version}",
96
+ SEPARATOR,
97
+ ]
98
+ for section in ("core", "hook", "optional"):
99
+ lines.append(f"[{section}]")
100
+ lines.extend(
101
+ f"{(CHECK_LABELS[check.code] + ':'):<19}{check.detail}"
102
+ for check_section, check in current.checks
103
+ if check_section == section
104
+ )
105
+ lines.append(SEPARATOR)
106
+ lines.extend(["self-heal log (last 5):", *current.self_heal_log])
107
+ return "\n".join(lines) + "\n"
108
+
109
+
110
+ def render_json(report: DoctorReport | None = None) -> str:
111
+ import json
112
+
113
+ current = report if report is not None else collect()
114
+ summary = {"ok": 0, "warn": 0, "error": 0}
115
+ checks = []
116
+ for section, check in current.checks:
117
+ summary[check.status] += 1
118
+ checks.append(
119
+ {
120
+ "section": section,
121
+ "code": check.code,
122
+ "status": check.status,
123
+ "detail": check.detail,
124
+ }
125
+ )
126
+ return json.dumps(
127
+ {
128
+ "version": current.version,
129
+ "checks": checks,
130
+ "self_heal_log": current.self_heal_log,
131
+ "summary": summary,
132
+ },
133
+ ensure_ascii=False,
134
+ indent=2,
135
+ ) + "\n"
136
+
137
+
138
+ def exit_code(report: DoctorReport) -> int:
139
+ return int(any(check.status == "error" for _, check in report.checks))
140
+
141
+
142
+ def _field(code: str, func: Callable[[], CheckResult]) -> CheckResult:
143
+ try:
144
+ return func()
145
+ except Exception as exc:
146
+ return CheckResult(code=code, status="error", detail=f"error: {exc}")
147
+
148
+
149
+ def _text_field(func: Callable[[], str]) -> str:
150
+ try:
151
+ return func()
152
+ except Exception as exc:
153
+ return f"error: {exc}"
154
+
155
+
156
+ def _current_version() -> str:
157
+ try:
158
+ return metadata.version("usage-cli")
159
+ except metadata.PackageNotFoundError:
160
+ pyproject = packaged_resource_path(
161
+ "pyproject.toml", Path(__file__).with_name("pyproject.toml")
162
+ )
163
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
164
+ version = data.get("project", {}).get("version")
165
+ if isinstance(version, str):
166
+ return version
167
+ raise RuntimeError("project.version missing from pyproject.toml") from None
168
+
169
+
170
+ def _hook_state() -> CheckResult:
171
+ state = setup_hook._detect_current_state()
172
+ status = "ok" if state in {"us-direct", "us-forwarder"} else "warn"
173
+ return CheckResult(code=HOOK_STATE, status=status, detail=state)
174
+
175
+
176
+ def _hook_version() -> CheckResult:
177
+ installed = setup_hook._installed_hook_version()
178
+ if installed is None:
179
+ return CheckResult(
180
+ code=HOOK_VERSION,
181
+ status="warn",
182
+ detail=f"not installed (current {setup_hook.HOOK_VERSION})",
183
+ )
184
+ suffix = (
185
+ "current"
186
+ if installed == setup_hook.HOOK_VERSION
187
+ else f"current {setup_hook.HOOK_VERSION}"
188
+ )
189
+ status = "ok" if installed == setup_hook.HOOK_VERSION else "warn"
190
+ return CheckResult(code=HOOK_VERSION, status=status, detail=f"{installed} ({suffix})")
191
+
192
+
193
+ def _script_status(path: Path) -> CheckResult:
194
+ display = _display_path(path)
195
+ exists = path.exists()
196
+ return CheckResult(
197
+ code=HOOK_SCRIPT,
198
+ status="ok" if exists else "warn",
199
+ detail=f"{display} [{'ok' if exists else 'missing'}]",
200
+ )
201
+
202
+
203
+ def _forwarder_script_status() -> CheckResult:
204
+ path = setup_hook.FORWARDER_TARGET
205
+ display = _display_path(path)
206
+ if path.exists():
207
+ return CheckResult(
208
+ code=FORWARDER_SCRIPT,
209
+ status="ok",
210
+ detail=f"{display} [ok]",
211
+ )
212
+ state = setup_hook._detect_current_state()
213
+ status = "warn" if state == "us-forwarder" else "ok"
214
+ detail = "missing" if state == "us-forwarder" else f"not needed in {state} mode"
215
+ return CheckResult(
216
+ code=FORWARDER_SCRIPT,
217
+ status=status,
218
+ detail=f"{display} [{detail}]",
219
+ )
220
+
221
+
222
+ def _status_file() -> CheckResult:
223
+ path = setup_hook.STATUS_FILE
224
+ display = _display_path(path)
225
+ if not path.exists():
226
+ return CheckResult(code=STATUS_FILE, status="warn", detail=f"{display} [missing]")
227
+ return CheckResult(
228
+ code=STATUS_FILE,
229
+ status="ok",
230
+ detail=f"{display} (wrote {_ago(path.stat().st_mtime)} ago)",
231
+ )
232
+
233
+
234
+ def _status_command() -> CheckResult:
235
+ settings = setup_hook._load_settings()
236
+ sl = settings.get("statusLine")
237
+ command = sl.get("command") if isinstance(sl, dict) else None
238
+ if not isinstance(command, str):
239
+ return CheckResult(code=STATUS_COMMAND, status="warn", detail="not configured")
240
+ if (
241
+ sys.platform == "win32"
242
+ and "usage-statusline" in command
243
+ and "\\" in command
244
+ ):
245
+ return CheckResult(
246
+ code=STATUS_COMMAND,
247
+ status="warn",
248
+ detail=(
249
+ "Windows Git Bash-incompatible paths; run usage --setup, then restart Claude Code"
250
+ ),
251
+ )
252
+ return CheckResult(code=STATUS_COMMAND, status="ok", detail="ok")
253
+
254
+
255
+ def _external_hooks() -> CheckResult:
256
+ state = setup_hook._detect_current_state()
257
+ if state != "external":
258
+ return CheckResult(code=EXTERNAL_HOOKS, status="ok", detail="none detected")
259
+ settings = setup_hook._load_settings()
260
+ sl = settings.get("statusLine")
261
+ command = sl.get("command") if isinstance(sl, dict) else None
262
+ if not isinstance(command, str):
263
+ return CheckResult(
264
+ code=EXTERNAL_HOOKS,
265
+ status="warn",
266
+ detail="external (unrecognized)",
267
+ )
268
+ keyword = _external_keyword(command)
269
+ return CheckResult(
270
+ code=EXTERNAL_HOOKS,
271
+ status="warn",
272
+ detail=keyword if keyword else "external (unrecognized)",
273
+ )
274
+
275
+
276
+ def _forwarder_prompt() -> CheckResult:
277
+ settings = setup_hook._load_settings()
278
+ usage = settings.get(setup_hook.BACKUP_KEY)
279
+ if isinstance(usage, dict) and usage.get("forwarderModePromptDismissed") is True:
280
+ return CheckResult(code=FORWARDER_PROMPT, status="ok", detail="acked")
281
+ return CheckResult(code=FORWARDER_PROMPT, status="ok", detail="not acked")
282
+
283
+
284
+ def _self_heal_log_lines() -> list[str]:
285
+ try:
286
+ settings = setup_hook._load_settings()
287
+ usage = settings.get(setup_hook.BACKUP_KEY)
288
+ log = usage.get("selfHealLog") if isinstance(usage, dict) else None
289
+ if not isinstance(log, list) or not log:
290
+ return [" none"]
291
+ lines: list[str] = []
292
+ for item in log[-5:]:
293
+ if not isinstance(item, dict):
294
+ continue
295
+ timestamp = str(item.get("timestamp", "unknown"))
296
+ action = str(item.get("action", "unknown"))
297
+ detail = str(item.get("detail", ""))
298
+ lines.append(f" {timestamp} {action:<22} {detail}".rstrip())
299
+ return lines or [" none"]
300
+ except Exception as exc:
301
+ return [f" error: {exc}"]
302
+
303
+
304
+ def _codex_sessions() -> CheckResult:
305
+ import codex_loader
306
+
307
+ sessions_dir = codex_loader.SESSIONS_DIR
308
+ if not sessions_dir.is_dir():
309
+ return CheckResult(
310
+ code=CODEX_SESSIONS,
311
+ status="warn",
312
+ detail="0 files, missing sessions dir",
313
+ )
314
+ count = 0
315
+ newest_mtime = 0.0
316
+ for path in sessions_dir.rglob("*.jsonl"):
317
+ count += 1
318
+ try:
319
+ newest_mtime = max(newest_mtime, path.stat().st_mtime)
320
+ except OSError:
321
+ continue
322
+ if newest_mtime <= 0:
323
+ return CheckResult(
324
+ code=CODEX_SESSIONS,
325
+ status="warn",
326
+ detail=f"{count} files, no readable mtimes",
327
+ )
328
+ return CheckResult(
329
+ code=CODEX_SESSIONS,
330
+ status="ok",
331
+ detail=f"{count} files, latest wrote {_ago(newest_mtime)} ago",
332
+ )
333
+
334
+
335
+ def _codex_logs() -> CheckResult:
336
+ import codex_loader
337
+
338
+ logs_db = codex_loader.LOGS_DB
339
+ if not logs_db.exists():
340
+ return CheckResult(
341
+ code=CODEX_LOGS,
342
+ status="warn",
343
+ detail=f"{_display_path(logs_db)} [missing], rate_limit rows: 0",
344
+ )
345
+ rows = _codex_rate_limit_log_count(logs_db)
346
+ return CheckResult(
347
+ code=CODEX_LOGS,
348
+ status="ok",
349
+ detail=f"{_display_path(logs_db)} [ok], rate_limit rows: {rows}",
350
+ )
351
+
352
+
353
+ def _codex_rate_limit_log_count(logs_db: Path) -> int:
354
+ query = (
355
+ "SELECT count(*) FROM logs "
356
+ "WHERE feedback_log_body LIKE '%codex.rate_limits%' "
357
+ "OR feedback_log_body LIKE '%usage_limit_reached%'"
358
+ )
359
+ with sqlite3.connect(f"{logs_db.resolve().as_uri()}?mode=ro", uri=True) as conn:
360
+ value = conn.execute(query).fetchone()[0]
361
+ return int(value)
362
+
363
+
364
+ def _codex_state() -> CheckResult:
365
+ import codex_loader
366
+
367
+ state_db = codex_loader.STATE_DB
368
+ exists = state_db.exists()
369
+ return CheckResult(
370
+ code=CODEX_STATE,
371
+ status="ok" if exists else "warn",
372
+ detail=f"{_display_path(state_db)} [{'ok' if exists else 'missing'}]",
373
+ )
374
+
375
+
376
+ def _codex_rate_limits() -> CheckResult:
377
+ import codex_loader
378
+
379
+ rate_limits = codex_loader.load_rate_limits()
380
+ if rate_limits is None:
381
+ return CheckResult(code=CODEX_RATE_LIMITS, status="warn", detail="none")
382
+ five = "yes" if rate_limits.five_hour_pct is not None else "no"
383
+ weekly = "yes" if rate_limits.seven_day_pct is not None else "no"
384
+ updated = _rate_limits_updated_age(rate_limits.updated_at)
385
+ has_limit = rate_limits.five_hour_pct is not None or rate_limits.seven_day_pct is not None
386
+ status = "ok" if has_limit and _rate_limits_are_fresh(rate_limits.updated_at) else "warn"
387
+ return CheckResult(
388
+ code=CODEX_RATE_LIMITS,
389
+ status=status,
390
+ detail=f"5h: {five}, weekly: {weekly}, updated: {updated}",
391
+ )
392
+
393
+
394
+ def _rate_limits_are_fresh(updated_at: str) -> bool:
395
+ if not updated_at:
396
+ return False
397
+ timestamp = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
398
+ if timestamp.tzinfo is None:
399
+ timestamp = timestamp.replace(tzinfo=UTC)
400
+ else:
401
+ timestamp = timestamp.astimezone(UTC)
402
+ age_seconds = datetime.now(UTC).timestamp() - timestamp.timestamp()
403
+ return 0 <= age_seconds <= RATE_LIMIT_FRESH_SECONDS
404
+
405
+
406
+ def _rate_limits_updated_age(updated_at: str) -> str:
407
+ if not updated_at:
408
+ return "unknown"
409
+ timestamp = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
410
+ if timestamp.tzinfo is None:
411
+ timestamp = timestamp.replace(tzinfo=UTC)
412
+ else:
413
+ timestamp = timestamp.astimezone(UTC)
414
+ return f"{_ago(timestamp.timestamp())} ago"
415
+
416
+
417
+ def _external_keyword(command: str) -> str | None:
418
+ try:
419
+ parts = shlex.split(command)
420
+ except ValueError:
421
+ parts = command.split()
422
+ for part in parts:
423
+ token = part.lower()
424
+ basename = Path(part).name.lower()
425
+ for keyword in ("ccusage", "lord-kali"):
426
+ if keyword in token or keyword in basename:
427
+ return keyword
428
+ return None
429
+
430
+
431
+ def _display_path(path: Path) -> str:
432
+ home = str(Path.home())
433
+ text = str(path)
434
+ if text == home:
435
+ return "~"
436
+ if text.startswith(home + os.sep):
437
+ return "~" + text[len(home) :]
438
+ return text
439
+
440
+
441
+ def _ago(mtime: float) -> str:
442
+ seconds = max(0, int(datetime.now(UTC).timestamp() - mtime))
443
+ if seconds < 60:
444
+ return f"{seconds}s"
445
+ minutes = seconds // 60
446
+ if minutes < 60:
447
+ return f"{minutes}m"
448
+ hours = minutes // 60
449
+ if hours < 48:
450
+ return f"{hours}h"
451
+ days = hours // 24
452
+ return f"{days}d"