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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- 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)
|
disk_cache_lifecycle.py
ADDED
|
@@ -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"
|