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
usage_statusline.py
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
4
|
+
#
|
|
5
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
6
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
7
|
+
|
|
8
|
+
# ruff: noqa: SIM105, UP006, UP035, UP045
|
|
9
|
+
"""Claude Code statusLine hook:把 Claude Code 推來的狀態 JSON 持久化並渲染狀態列。
|
|
10
|
+
|
|
11
|
+
Claude Code 每次刷新 statusLine 時會把當前 session 的完整 JSON
|
|
12
|
+
(含 rate_limits.five_hour / seven_day、context_window、cost 等)
|
|
13
|
+
從 stdin 傳給這個 script。我們會落地到 usage-status.json,
|
|
14
|
+
再輸出多行彩色 statusLine 文字供 Claude Code 顯示。
|
|
15
|
+
|
|
16
|
+
usage 主程式會反向讀這個檔,呈現給 menubar / TUI。
|
|
17
|
+
|
|
18
|
+
刻意只用標準庫,方便用系統 python3 跑。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import errno
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from contextlib import contextmanager, suppress
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import Any, Dict, List, Optional, Tuple, cast
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _configure_windows_utf8_output() -> None:
|
|
35
|
+
"""Make hook output UTF-8 when Claude Code reads a Windows pipe."""
|
|
36
|
+
if os.name != "nt":
|
|
37
|
+
return
|
|
38
|
+
for stream in (sys.stdout, sys.stderr):
|
|
39
|
+
with suppress(AttributeError, OSError, ValueError):
|
|
40
|
+
# Test runners and embedders may replace the TextIOWrapper streams.
|
|
41
|
+
cast(Any, stream).reconfigure(encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_stdin_utf8() -> str:
|
|
45
|
+
buffer = getattr(sys.stdin, "buffer", None)
|
|
46
|
+
if buffer is None:
|
|
47
|
+
return sys.stdin.read()
|
|
48
|
+
return cast(bytes, buffer.read()).decode("utf-8", "replace")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_fcntl: Any = None
|
|
52
|
+
_msvcrt: Any = None
|
|
53
|
+
if sys.platform == "win32":
|
|
54
|
+
try:
|
|
55
|
+
import msvcrt
|
|
56
|
+
except ImportError:
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
_msvcrt = msvcrt
|
|
60
|
+
else:
|
|
61
|
+
try:
|
|
62
|
+
import fcntl
|
|
63
|
+
except ImportError:
|
|
64
|
+
pass
|
|
65
|
+
else:
|
|
66
|
+
_fcntl = fcntl
|
|
67
|
+
fcntl = _fcntl
|
|
68
|
+
msvcrt = _msvcrt
|
|
69
|
+
|
|
70
|
+
__version__ = "1.1"
|
|
71
|
+
|
|
72
|
+
STATUS_FILE = os.path.expanduser("~/.claude/usage-status.json")
|
|
73
|
+
LOCK_FILE = os.path.expanduser("~/.claude/usage-status.lock")
|
|
74
|
+
# Windows lock acquisition: poll rather than fail instantly on contention.
|
|
75
|
+
_LOCK_TIMEOUT_S = 10.0
|
|
76
|
+
_LOCK_POLL_INTERVAL_S = 0.001
|
|
77
|
+
# msvcrt.locking reports a contended byte as EACCES; EDEADLOCK is what it
|
|
78
|
+
# raises once its own internal retries are exhausted.
|
|
79
|
+
_LOCK_CONTENDED_ERRNOS = frozenset(
|
|
80
|
+
(errno.EACCES, getattr(errno, "EDEADLOCK", errno.EDEADLK), errno.EDEADLK)
|
|
81
|
+
)
|
|
82
|
+
PREFERENCES_FILE = os.path.expanduser("~/.claude/usage-preferences.json")
|
|
83
|
+
CONTEXT_BURN_FILE = os.path.expanduser("~/.claude/usage-context-burn.json")
|
|
84
|
+
UPDATE_HINT_STALE_SECONDS = 30 * 86400
|
|
85
|
+
# Context fill at which a /clear or /compact nudge is worth the noise. Set below
|
|
86
|
+
# the default auto-compact line (~80%) so the user can act before the lossy
|
|
87
|
+
# automatic pass decides for them. Long contexts degrade quality well before they
|
|
88
|
+
# fill: models lose the middle of long inputs and effective context is often only
|
|
89
|
+
# ~50-65% of the window. Refs: Liu et al. "Lost in the Middle" (2023,
|
|
90
|
+
# arXiv:2307.03172), NVIDIA RULER (2024, arXiv:2404.06654), BABILong (2024,
|
|
91
|
+
# arXiv:2406.10149).
|
|
92
|
+
HEAVY_CONTEXT_PERCENT = 70.0
|
|
93
|
+
CONTEXT_BURN_RESET_DROP_PERCENT = 5.0
|
|
94
|
+
CONTEXT_BURN_STALE_SECONDS = 4 * 60 * 60
|
|
95
|
+
CONTEXT_BURN_FAST_PERCENT_PER_MIN = 2.0
|
|
96
|
+
CONTEXT_BURN_VERY_FAST_PERCENT_PER_MIN = 4.0
|
|
97
|
+
CONTEXT_BURN_FAST_THRESHOLD_PERCENT = 60.0
|
|
98
|
+
CONTEXT_BURN_VERY_FAST_THRESHOLD_PERCENT = 55.0
|
|
99
|
+
CONTEXT_BURN_THRESHOLD_FLOOR_PERCENT = 55.0
|
|
100
|
+
# 2%/min reaches the old warning line from 50% in about 10 minutes; 4%/min is
|
|
101
|
+
# the "large paste or long replay" case that should warn at the floor.
|
|
102
|
+
STATUSLINE_TRANSLATIONS = {
|
|
103
|
+
"zh-TW": {
|
|
104
|
+
"five_hour": "5小時",
|
|
105
|
+
"seven_day": "7天",
|
|
106
|
+
"context": "對話窗",
|
|
107
|
+
"total": "累計",
|
|
108
|
+
"in_short": "問:",
|
|
109
|
+
"out_short": "答:",
|
|
110
|
+
"this_turn": "本輪",
|
|
111
|
+
"cached": "快取:",
|
|
112
|
+
"cost": "花費:",
|
|
113
|
+
"session_dur": "會話時長:",
|
|
114
|
+
"remaining_prefix": "剩",
|
|
115
|
+
"effort_xhigh": "深思熟慮",
|
|
116
|
+
"effort_high": "深思",
|
|
117
|
+
"effort_normal": "標準",
|
|
118
|
+
"effort_low": "速答",
|
|
119
|
+
"fast_mode": "⚡快速",
|
|
120
|
+
"update_available_suffix": "可更新",
|
|
121
|
+
"warn_clear": "越長 AI 越容易漏記中段 · 切任務 /clear,續做 /compact 留重點",
|
|
122
|
+
},
|
|
123
|
+
"zh-CN": {
|
|
124
|
+
"five_hour": "5小时",
|
|
125
|
+
"seven_day": "7天",
|
|
126
|
+
"context": "对话窗",
|
|
127
|
+
"total": "累计",
|
|
128
|
+
"in_short": "问:",
|
|
129
|
+
"out_short": "答:",
|
|
130
|
+
"this_turn": "本轮",
|
|
131
|
+
"cached": "缓存:",
|
|
132
|
+
"cost": "花费:",
|
|
133
|
+
"session_dur": "会话时长:",
|
|
134
|
+
"remaining_prefix": "剩",
|
|
135
|
+
"effort_xhigh": "深思熟虑",
|
|
136
|
+
"effort_high": "深思",
|
|
137
|
+
"effort_normal": "标准",
|
|
138
|
+
"effort_low": "速答",
|
|
139
|
+
"fast_mode": "⚡快速",
|
|
140
|
+
"update_available_suffix": "可更新",
|
|
141
|
+
"warn_clear": "越长 AI 越容易漏记中段 · 切任务 /clear,续做 /compact 留重点",
|
|
142
|
+
},
|
|
143
|
+
"en": {
|
|
144
|
+
"five_hour": "5h",
|
|
145
|
+
"seven_day": "7d",
|
|
146
|
+
"context": "Context",
|
|
147
|
+
"total": "Total",
|
|
148
|
+
"in_short": "in:",
|
|
149
|
+
"out_short": "out:",
|
|
150
|
+
"this_turn": "this turn",
|
|
151
|
+
"cached": "Cached:",
|
|
152
|
+
"cost": "Cost:",
|
|
153
|
+
"session_dur": "Session:",
|
|
154
|
+
"remaining_prefix": "left",
|
|
155
|
+
"effort_xhigh": "Extended",
|
|
156
|
+
"effort_high": "Deep",
|
|
157
|
+
"effort_normal": "Standard",
|
|
158
|
+
"effort_low": "Quick",
|
|
159
|
+
"fast_mode": "⚡Fast",
|
|
160
|
+
"update_available_suffix": "available",
|
|
161
|
+
"warn_clear": "longer chats lose the middle · /clear to switch, /compact to keep focus",
|
|
162
|
+
},
|
|
163
|
+
"ja": {
|
|
164
|
+
"five_hour": "5時間",
|
|
165
|
+
"seven_day": "7日",
|
|
166
|
+
"context": "コンテキスト",
|
|
167
|
+
"total": "累計",
|
|
168
|
+
"in_short": "入:",
|
|
169
|
+
"out_short": "出:",
|
|
170
|
+
"this_turn": "今回",
|
|
171
|
+
"cached": "キャッシュ:",
|
|
172
|
+
"cost": "費用:",
|
|
173
|
+
"session_dur": "セッション時間:",
|
|
174
|
+
"remaining_prefix": "残り",
|
|
175
|
+
"effort_xhigh": "熟考",
|
|
176
|
+
"effort_high": "熟考",
|
|
177
|
+
"effort_normal": "標準",
|
|
178
|
+
"effort_low": "即答",
|
|
179
|
+
"fast_mode": "⚡高速",
|
|
180
|
+
"update_available_suffix": "更新あり",
|
|
181
|
+
"warn_clear": "長いほど中盤を忘れがち · 切替は /clear、継続は /compact で要点保持",
|
|
182
|
+
},
|
|
183
|
+
"ko": {
|
|
184
|
+
"five_hour": "5시간",
|
|
185
|
+
"seven_day": "7일",
|
|
186
|
+
"context": "컨텍스트",
|
|
187
|
+
"total": "누적",
|
|
188
|
+
"in_short": "입:",
|
|
189
|
+
"out_short": "출:",
|
|
190
|
+
"this_turn": "이번 턴",
|
|
191
|
+
"cached": "캐시:",
|
|
192
|
+
"cost": "비용:",
|
|
193
|
+
"session_dur": "세션 시간:",
|
|
194
|
+
"remaining_prefix": "남음",
|
|
195
|
+
"effort_xhigh": "심사숙고",
|
|
196
|
+
"effort_high": "깊은 사고",
|
|
197
|
+
"effort_normal": "표준",
|
|
198
|
+
"effort_low": "빠른 답변",
|
|
199
|
+
"fast_mode": "⚡빠름",
|
|
200
|
+
"update_available_suffix": "업데이트",
|
|
201
|
+
"warn_clear": "길수록 중간 내용을 놓침 · 전환은 /clear, 계속은 /compact로 핵심 유지",
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
C = {
|
|
205
|
+
"green": "\033[38;5;80m",
|
|
206
|
+
"yellow": "\033[33m",
|
|
207
|
+
"red": "\033[31m",
|
|
208
|
+
"cyan": "\033[36m",
|
|
209
|
+
"blue": "\033[38;5;39m",
|
|
210
|
+
"magenta": "\033[38;5;111m",
|
|
211
|
+
"peach": "\033[38;5;216m",
|
|
212
|
+
"dim": "\033[2m",
|
|
213
|
+
"reset": "\033[0m",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _windows_system_lang() -> str:
|
|
218
|
+
if os.name != "nt":
|
|
219
|
+
return ""
|
|
220
|
+
try:
|
|
221
|
+
import ctypes
|
|
222
|
+
import locale as _locale
|
|
223
|
+
|
|
224
|
+
windll = getattr(ctypes, "windll", None)
|
|
225
|
+
if windll is None:
|
|
226
|
+
return ""
|
|
227
|
+
lang_id = int(windll.kernel32.GetUserDefaultUILanguage())
|
|
228
|
+
return _locale.windows_locale.get(lang_id, "") or ""
|
|
229
|
+
except Exception:
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _statusline_detect_lang(env: Optional[Dict[str, str]] = None) -> str:
|
|
234
|
+
source = os.environ if env is None else env
|
|
235
|
+
raw = ""
|
|
236
|
+
# Windows 上的 LANG 多半是 Git Bash / MSYS 帶進來的,不代表使用者的系統語言。
|
|
237
|
+
keys = (
|
|
238
|
+
("USAGE_LANG", "TT_LANG") if sys.platform == "win32" else ("USAGE_LANG", "TT_LANG", "LANG")
|
|
239
|
+
)
|
|
240
|
+
for key in keys:
|
|
241
|
+
value = source.get(key, "").strip()
|
|
242
|
+
if value:
|
|
243
|
+
raw = value
|
|
244
|
+
break
|
|
245
|
+
if not raw and env is None:
|
|
246
|
+
raw = _windows_system_lang()
|
|
247
|
+
code = raw.split(".")[0].replace("_", "-")
|
|
248
|
+
table = {
|
|
249
|
+
"zh-TW": "zh-TW",
|
|
250
|
+
"zh-HK": "zh-TW",
|
|
251
|
+
"zh-CN": "zh-CN",
|
|
252
|
+
"zh": "zh-CN",
|
|
253
|
+
"ja-JP": "ja",
|
|
254
|
+
"ja": "ja",
|
|
255
|
+
"ko-KR": "ko",
|
|
256
|
+
"ko": "ko",
|
|
257
|
+
}
|
|
258
|
+
return table.get(code, "en")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _detect_lang() -> str:
|
|
262
|
+
return _statusline_detect_lang()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _t(key: str) -> str:
|
|
266
|
+
lang = _detect_lang()
|
|
267
|
+
table = STATUSLINE_TRANSLATIONS.get(lang, STATUSLINE_TRANSLATIONS["en"])
|
|
268
|
+
return table.get(key, key)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _read_update_hint(now_ts: float) -> Optional[str]:
|
|
272
|
+
"""Return latest_version when an update is fresh, available, and not skipped."""
|
|
273
|
+
try:
|
|
274
|
+
with open(PREFERENCES_FILE, encoding="utf-8") as f:
|
|
275
|
+
prefs = json.load(f)
|
|
276
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
277
|
+
return None
|
|
278
|
+
if not isinstance(prefs, dict):
|
|
279
|
+
return None
|
|
280
|
+
info = prefs.get("last_update_check")
|
|
281
|
+
if not isinstance(info, dict):
|
|
282
|
+
return None
|
|
283
|
+
latest = info.get("latest_version")
|
|
284
|
+
current = info.get("current_version")
|
|
285
|
+
checked_at = info.get("checked_at")
|
|
286
|
+
if not isinstance(latest, str) or not isinstance(current, str):
|
|
287
|
+
return None
|
|
288
|
+
if not isinstance(checked_at, (int, float)) or isinstance(checked_at, bool):
|
|
289
|
+
return None
|
|
290
|
+
if latest == current:
|
|
291
|
+
return None
|
|
292
|
+
if prefs.get("update_skipped_version") == latest:
|
|
293
|
+
return None
|
|
294
|
+
if now_ts - float(checked_at) > UPDATE_HINT_STALE_SECONDS:
|
|
295
|
+
return None
|
|
296
|
+
return latest
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _rate_limits_complete(rate_limits: Any) -> bool:
|
|
300
|
+
if not isinstance(rate_limits, dict):
|
|
301
|
+
return False
|
|
302
|
+
five = rate_limits.get("five_hour")
|
|
303
|
+
seven = rate_limits.get("seven_day")
|
|
304
|
+
if not isinstance(five, dict) or not isinstance(seven, dict):
|
|
305
|
+
return False
|
|
306
|
+
return five.get("used_percentage") is not None and seven.get("used_percentage") is not None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _acquire_msvcrt_lock(lock_fd: int) -> bool:
|
|
310
|
+
"""Block (bounded) until the byte is locked; False if locking is unavailable.
|
|
311
|
+
|
|
312
|
+
msvcrt has no blocking-with-timeout mode: LK_LOCK retries on a fixed
|
|
313
|
+
one-second granularity, and LK_NBLCK gives up instantly. Poll LK_NBLCK so a
|
|
314
|
+
contended lock is waited out rather than silently skipped — dropping the
|
|
315
|
+
lock would run save()'s read-modify-write unsynchronized, which fcntl.flock
|
|
316
|
+
never does on POSIX.
|
|
317
|
+
"""
|
|
318
|
+
deadline = time.monotonic() + _LOCK_TIMEOUT_S
|
|
319
|
+
while True:
|
|
320
|
+
try:
|
|
321
|
+
if os.fstat(lock_fd).st_size == 0:
|
|
322
|
+
os.write(lock_fd, b"\0")
|
|
323
|
+
os.lseek(lock_fd, 0, os.SEEK_SET)
|
|
324
|
+
msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
|
|
325
|
+
return True
|
|
326
|
+
except OSError as exc:
|
|
327
|
+
if exc.errno not in _LOCK_CONTENDED_ERRNOS:
|
|
328
|
+
# Locking is unsupported on this descriptor or filesystem;
|
|
329
|
+
# spinning would not help. Fall back to the atomic write alone.
|
|
330
|
+
return False
|
|
331
|
+
if time.monotonic() >= deadline:
|
|
332
|
+
return False
|
|
333
|
+
time.sleep(_LOCK_POLL_INTERVAL_S)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@contextmanager
|
|
337
|
+
def _exclusive_lock(lock_fd: int) -> Any:
|
|
338
|
+
"""Use the native lock when available; preserve atomic writes without one."""
|
|
339
|
+
if fcntl is not None:
|
|
340
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
341
|
+
try:
|
|
342
|
+
yield
|
|
343
|
+
finally:
|
|
344
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
locked = _acquire_msvcrt_lock(lock_fd) if msvcrt is not None else False
|
|
348
|
+
try:
|
|
349
|
+
yield
|
|
350
|
+
finally:
|
|
351
|
+
if locked and msvcrt is not None:
|
|
352
|
+
try:
|
|
353
|
+
os.lseek(lock_fd, 0, os.SEEK_SET)
|
|
354
|
+
msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
|
|
355
|
+
except OSError:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def save(data: Dict[str, Any], now: datetime) -> None:
|
|
360
|
+
data["_received_at"] = now.isoformat()
|
|
361
|
+
data["_received_at_ts"] = now.timestamp()
|
|
362
|
+
target_dir = os.path.dirname(STATUS_FILE)
|
|
363
|
+
lock_file = os.path.join(target_dir, os.path.basename(LOCK_FILE))
|
|
364
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
365
|
+
os.makedirs(os.path.dirname(lock_file), exist_ok=True)
|
|
366
|
+
tmp_path: Optional[str] = None
|
|
367
|
+
lock_fd = os.open(lock_file, os.O_CREAT | os.O_RDWR, 0o600)
|
|
368
|
+
try:
|
|
369
|
+
with _exclusive_lock(lock_fd):
|
|
370
|
+
if not _rate_limits_complete(data.get("rate_limits")):
|
|
371
|
+
try:
|
|
372
|
+
with open(STATUS_FILE, encoding="utf-8") as f:
|
|
373
|
+
existing = json.load(f)
|
|
374
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
375
|
+
existing = None
|
|
376
|
+
if isinstance(existing, dict):
|
|
377
|
+
existing_rate_limits = existing.get("rate_limits")
|
|
378
|
+
if _rate_limits_complete(existing_rate_limits):
|
|
379
|
+
data["rate_limits"] = existing_rate_limits
|
|
380
|
+
fd, tmp_path = tempfile.mkstemp(dir=target_dir, suffix=".tmp")
|
|
381
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
382
|
+
json.dump(data, f, ensure_ascii=False)
|
|
383
|
+
os.replace(tmp_path, STATUS_FILE)
|
|
384
|
+
tmp_path = None
|
|
385
|
+
finally:
|
|
386
|
+
os.close(lock_fd)
|
|
387
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
388
|
+
try:
|
|
389
|
+
os.unlink(tmp_path)
|
|
390
|
+
except OSError:
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _debug(message: str, exc: Optional[Exception] = None) -> None:
|
|
395
|
+
if os.environ.get("USAGE_DEBUG") != "1":
|
|
396
|
+
return
|
|
397
|
+
if exc is None:
|
|
398
|
+
print(f"usage_statusline: {message}", file=sys.stderr)
|
|
399
|
+
return
|
|
400
|
+
print(f"usage_statusline: {message}: {exc}", file=sys.stderr)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def vlen(s: str) -> int:
|
|
404
|
+
visible = 0
|
|
405
|
+
i = 0
|
|
406
|
+
while i < len(s):
|
|
407
|
+
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
|
408
|
+
i += 2
|
|
409
|
+
while i < len(s) and s[i] != "m":
|
|
410
|
+
i += 1
|
|
411
|
+
i += 1
|
|
412
|
+
continue
|
|
413
|
+
visible += 1
|
|
414
|
+
i += 1
|
|
415
|
+
return visible
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _conout_columns() -> Optional[int]:
|
|
419
|
+
"""Return the active Windows console window width, if one is available."""
|
|
420
|
+
if os.name != "nt":
|
|
421
|
+
return None
|
|
422
|
+
try:
|
|
423
|
+
import ctypes
|
|
424
|
+
from ctypes import wintypes
|
|
425
|
+
|
|
426
|
+
class _CSBI(ctypes.Structure):
|
|
427
|
+
_fields_ = [
|
|
428
|
+
("dwSize", wintypes._COORD),
|
|
429
|
+
("dwCursorPosition", wintypes._COORD),
|
|
430
|
+
("wAttributes", wintypes.WORD),
|
|
431
|
+
("srWindow", wintypes.SMALL_RECT),
|
|
432
|
+
("dwMaximumWindowSize", wintypes._COORD),
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
win_dll = getattr(ctypes, "WinDLL", None)
|
|
436
|
+
if win_dll is None:
|
|
437
|
+
return None
|
|
438
|
+
kernel32 = win_dll("kernel32", use_last_error=True)
|
|
439
|
+
create_file = kernel32.CreateFileW
|
|
440
|
+
create_file.restype = wintypes.HANDLE
|
|
441
|
+
get_console_info = kernel32.GetConsoleScreenBufferInfo
|
|
442
|
+
get_console_info.argtypes = [wintypes.HANDLE, ctypes.POINTER(_CSBI)]
|
|
443
|
+
close_handle = kernel32.CloseHandle
|
|
444
|
+
close_handle.argtypes = [wintypes.HANDLE]
|
|
445
|
+
|
|
446
|
+
handle = create_file(
|
|
447
|
+
"CONOUT$",
|
|
448
|
+
0x80000000 | 0x40000000,
|
|
449
|
+
0x00000001 | 0x00000002,
|
|
450
|
+
None,
|
|
451
|
+
3,
|
|
452
|
+
0,
|
|
453
|
+
None,
|
|
454
|
+
)
|
|
455
|
+
invalid_handle_value = ctypes.c_void_p(-1).value
|
|
456
|
+
if handle in (None, 0, -1, invalid_handle_value):
|
|
457
|
+
return None
|
|
458
|
+
try:
|
|
459
|
+
csbi = _CSBI()
|
|
460
|
+
if not get_console_info(handle, ctypes.byref(csbi)):
|
|
461
|
+
return None
|
|
462
|
+
columns = csbi.srWindow.Right - csbi.srWindow.Left + 1
|
|
463
|
+
return columns if columns > 0 else None
|
|
464
|
+
finally:
|
|
465
|
+
close_handle(handle)
|
|
466
|
+
except Exception:
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def get_width() -> int:
|
|
471
|
+
try:
|
|
472
|
+
return max(1, os.get_terminal_size(2).columns - 4)
|
|
473
|
+
except Exception:
|
|
474
|
+
if os.name == "nt":
|
|
475
|
+
columns = _conout_columns()
|
|
476
|
+
if columns:
|
|
477
|
+
return max(1, columns - 4)
|
|
478
|
+
return 116
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def color_by_pct(pct: float) -> str:
|
|
482
|
+
if pct < 50:
|
|
483
|
+
return "\033[38;5;42m"
|
|
484
|
+
if pct < 80:
|
|
485
|
+
return "\033[38;5;214m"
|
|
486
|
+
return "\033[38;5;160m"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def fmt_tokens(n: Any) -> str:
|
|
490
|
+
try:
|
|
491
|
+
value = int(n)
|
|
492
|
+
except (TypeError, ValueError):
|
|
493
|
+
value = 0
|
|
494
|
+
if value >= 1_000_000:
|
|
495
|
+
return f"{value / 1_000_000:.1f}M"
|
|
496
|
+
if value >= 1_000:
|
|
497
|
+
return f"{value / 1_000:.0f}k"
|
|
498
|
+
return str(value)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def progress_bar(value: Any, bar_width: int = 8) -> str:
|
|
502
|
+
filled_char = "■"
|
|
503
|
+
empty_char = "□"
|
|
504
|
+
if value is None:
|
|
505
|
+
return empty_char * bar_width + " n/a"
|
|
506
|
+
pct = max(0.0, min(100.0, float(value)))
|
|
507
|
+
filled = round(pct / 100 * bar_width)
|
|
508
|
+
return (
|
|
509
|
+
f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}"
|
|
510
|
+
f"{empty_char * (bar_width - filled)} "
|
|
511
|
+
f"{color_by_pct(pct)}{pct:.0f}%{C['reset']}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def fmt_duration(seconds: float) -> str:
|
|
516
|
+
if seconds >= 86400:
|
|
517
|
+
d = int(seconds // 86400)
|
|
518
|
+
rem = int(seconds % 86400)
|
|
519
|
+
return f"{d}d{rem // 3600}h"
|
|
520
|
+
if seconds >= 3600:
|
|
521
|
+
h = int(seconds // 3600)
|
|
522
|
+
m = int((seconds % 3600) // 60)
|
|
523
|
+
return f"{h}h{m}m"
|
|
524
|
+
if seconds >= 60:
|
|
525
|
+
return f"{int(seconds // 60)}min"
|
|
526
|
+
return f"{int(seconds)}s"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def safe_text(value: str) -> str:
|
|
530
|
+
"""Drop control characters so untrusted names cannot rewrite the status line."""
|
|
531
|
+
return "".join(ch for ch in value if ch.isprintable())
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def git_branch(cwd: str) -> str:
|
|
535
|
+
path = os.path.abspath(cwd)
|
|
536
|
+
while True:
|
|
537
|
+
git_path = os.path.join(path, ".git")
|
|
538
|
+
if os.path.isdir(git_path):
|
|
539
|
+
head_path = os.path.join(git_path, "HEAD")
|
|
540
|
+
break
|
|
541
|
+
if os.path.isfile(git_path):
|
|
542
|
+
try:
|
|
543
|
+
with open(git_path, encoding="utf-8") as f:
|
|
544
|
+
target = f.read().strip()
|
|
545
|
+
if target.startswith("gitdir:"):
|
|
546
|
+
git_dir = target.split(":", 1)[1].strip()
|
|
547
|
+
if not os.path.isabs(git_dir):
|
|
548
|
+
git_dir = os.path.normpath(os.path.join(path, git_dir))
|
|
549
|
+
head_path = os.path.join(git_dir, "HEAD")
|
|
550
|
+
break
|
|
551
|
+
except OSError:
|
|
552
|
+
return ""
|
|
553
|
+
parent = os.path.dirname(path)
|
|
554
|
+
if parent == path:
|
|
555
|
+
return ""
|
|
556
|
+
path = parent
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
with open(head_path, encoding="utf-8") as f:
|
|
560
|
+
head = f.read().strip()
|
|
561
|
+
except OSError:
|
|
562
|
+
return ""
|
|
563
|
+
prefix = "ref: refs/heads/"
|
|
564
|
+
if head.startswith(prefix):
|
|
565
|
+
return head[len(prefix) :]
|
|
566
|
+
if head:
|
|
567
|
+
return head[:7]
|
|
568
|
+
return ""
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _as_dict(value: Any) -> Dict[str, Any]:
|
|
572
|
+
if isinstance(value, dict):
|
|
573
|
+
return value
|
|
574
|
+
return {}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _as_float(value: Any) -> Optional[float]:
|
|
578
|
+
try:
|
|
579
|
+
return float(value)
|
|
580
|
+
except (TypeError, ValueError):
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _read_context_burn_sample(now_ts: float) -> Optional[Tuple[float, float]]:
|
|
585
|
+
try:
|
|
586
|
+
with open(CONTEXT_BURN_FILE, encoding="utf-8") as f:
|
|
587
|
+
sample = json.load(f)
|
|
588
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
589
|
+
return None
|
|
590
|
+
if not isinstance(sample, dict):
|
|
591
|
+
return None
|
|
592
|
+
percent = _as_float(sample.get("percent"))
|
|
593
|
+
ts = _as_float(sample.get("ts"))
|
|
594
|
+
if percent is None or ts is None:
|
|
595
|
+
return None
|
|
596
|
+
if now_ts - ts > CONTEXT_BURN_STALE_SECONDS:
|
|
597
|
+
return None
|
|
598
|
+
return percent, ts
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _write_context_burn_sample(percent: float, now_ts: float) -> None:
|
|
602
|
+
target_dir = os.path.dirname(CONTEXT_BURN_FILE)
|
|
603
|
+
tmp_path: Optional[str] = None
|
|
604
|
+
try:
|
|
605
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
606
|
+
fd, tmp_path = tempfile.mkstemp(dir=target_dir, suffix=".tmp")
|
|
607
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
608
|
+
json.dump({"percent": percent, "ts": now_ts}, f, ensure_ascii=False)
|
|
609
|
+
os.replace(tmp_path, CONTEXT_BURN_FILE)
|
|
610
|
+
tmp_path = None
|
|
611
|
+
except OSError:
|
|
612
|
+
return
|
|
613
|
+
finally:
|
|
614
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
615
|
+
try:
|
|
616
|
+
os.unlink(tmp_path)
|
|
617
|
+
except OSError:
|
|
618
|
+
pass
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _context_burn_threshold(percent: float, now_ts: float) -> float:
|
|
622
|
+
previous = _read_context_burn_sample(now_ts)
|
|
623
|
+
_write_context_burn_sample(percent, now_ts)
|
|
624
|
+
if previous is None:
|
|
625
|
+
return HEAVY_CONTEXT_PERCENT
|
|
626
|
+
|
|
627
|
+
previous_percent, previous_ts = previous
|
|
628
|
+
if previous_percent - percent > CONTEXT_BURN_RESET_DROP_PERCENT:
|
|
629
|
+
return HEAVY_CONTEXT_PERCENT
|
|
630
|
+
|
|
631
|
+
elapsed_seconds = now_ts - previous_ts
|
|
632
|
+
if elapsed_seconds <= 0:
|
|
633
|
+
return HEAVY_CONTEXT_PERCENT
|
|
634
|
+
|
|
635
|
+
increase = percent - previous_percent
|
|
636
|
+
if increase <= 0:
|
|
637
|
+
return HEAVY_CONTEXT_PERCENT
|
|
638
|
+
|
|
639
|
+
percent_per_minute = increase / (elapsed_seconds / 60.0)
|
|
640
|
+
threshold = HEAVY_CONTEXT_PERCENT
|
|
641
|
+
if percent_per_minute >= CONTEXT_BURN_VERY_FAST_PERCENT_PER_MIN:
|
|
642
|
+
threshold = CONTEXT_BURN_VERY_FAST_THRESHOLD_PERCENT
|
|
643
|
+
elif percent_per_minute >= CONTEXT_BURN_FAST_PERCENT_PER_MIN:
|
|
644
|
+
threshold = CONTEXT_BURN_FAST_THRESHOLD_PERCENT
|
|
645
|
+
return max(CONTEXT_BURN_THRESHOLD_FLOOR_PERCENT, threshold)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _heavy_warning(data: Dict[str, Any], now_ts: Optional[float] = None) -> Optional[str]:
|
|
649
|
+
"""A /clear or /compact nudge once the context window gets heavy enough
|
|
650
|
+
that quality starts to slip (see HEAVY_CONTEXT_PERCENT)."""
|
|
651
|
+
pct = _as_float(_as_dict(data.get("context_window")).get("used_percentage"))
|
|
652
|
+
if pct is None:
|
|
653
|
+
return None
|
|
654
|
+
threshold = _context_burn_threshold(
|
|
655
|
+
pct,
|
|
656
|
+
datetime.now(timezone.utc).timestamp() if now_ts is None else now_ts,
|
|
657
|
+
)
|
|
658
|
+
if pct < threshold:
|
|
659
|
+
return None
|
|
660
|
+
detail = f"{_t('context')} {pct:.0f}%"
|
|
661
|
+
return f"\033[38;5;160m⚠ {detail} · {_t('warn_clear')}{C['reset']}"
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _render_core(data: Dict[str, Any], now: datetime) -> str:
|
|
665
|
+
width = get_width()
|
|
666
|
+
ctx = _as_dict(data.get("context_window"))
|
|
667
|
+
bar_w = 8 if width >= 100 else 6 if width >= 60 else 4
|
|
668
|
+
lang = _detect_lang()
|
|
669
|
+
|
|
670
|
+
line1: List[str] = []
|
|
671
|
+
project = _as_dict(data.get("workspace")).get("project_dir", "")
|
|
672
|
+
if isinstance(project, str) and project:
|
|
673
|
+
name = safe_text(os.path.basename(project))
|
|
674
|
+
branch = safe_text(git_branch(project))
|
|
675
|
+
if branch:
|
|
676
|
+
line1.append(f"{C['green']}{name}{C['reset']}({C['magenta']}{branch}{C['reset']})")
|
|
677
|
+
else:
|
|
678
|
+
line1.append(f"{C['green']}{name}{C['reset']}")
|
|
679
|
+
|
|
680
|
+
rl = _as_dict(data.get("rate_limits"))
|
|
681
|
+
rl_parts: List[Tuple[str, str, str]] = []
|
|
682
|
+
for key, label in (("five_hour", _t("five_hour")), ("seven_day", _t("seven_day"))):
|
|
683
|
+
entry = _as_dict(rl.get(key))
|
|
684
|
+
pct = entry.get("used_percentage")
|
|
685
|
+
if pct is None:
|
|
686
|
+
continue
|
|
687
|
+
pct_float = _as_float(pct)
|
|
688
|
+
if pct_float is None:
|
|
689
|
+
continue
|
|
690
|
+
reset_str = ""
|
|
691
|
+
resets_at = _as_float(entry.get("resets_at"))
|
|
692
|
+
if resets_at is not None:
|
|
693
|
+
remain = int(resets_at) - int(now.timestamp())
|
|
694
|
+
if remain > 0:
|
|
695
|
+
if lang in ("zh-TW", "zh-CN"):
|
|
696
|
+
reset_str = f" ({_t('remaining_prefix')}{fmt_duration(remain)})"
|
|
697
|
+
else:
|
|
698
|
+
reset_str = f" ({fmt_duration(remain)} {_t('remaining_prefix')})"
|
|
699
|
+
rl_parts.append(
|
|
700
|
+
(
|
|
701
|
+
f"{C['blue']}{label}:{C['reset']}{progress_bar(pct_float, bar_w)}{reset_str}",
|
|
702
|
+
f"{C['blue']}{label}:{C['reset']}{progress_bar(pct_float, bar_w)}",
|
|
703
|
+
f"{C['blue']}{label}:{C['reset']}{pct_float:.0f}%",
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
ctx_parts: List[str] = []
|
|
708
|
+
ctx_pct = _as_float(ctx.get("used_percentage"))
|
|
709
|
+
if ctx_pct is not None:
|
|
710
|
+
size = ctx.get("context_window_size", 0)
|
|
711
|
+
ctx_parts = [
|
|
712
|
+
f"{C['blue']}{_t('context')}:{C['reset']}"
|
|
713
|
+
f"{progress_bar(ctx_pct, bar_w)} / {fmt_tokens(size)}",
|
|
714
|
+
f"{C['blue']}{_t('context')}:{C['reset']}{ctx_pct:.0f}%",
|
|
715
|
+
]
|
|
716
|
+
|
|
717
|
+
full = line1 + [p[0] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
|
|
718
|
+
candidate = " | ".join(full)
|
|
719
|
+
if vlen(candidate) <= width:
|
|
720
|
+
line1 = full
|
|
721
|
+
else:
|
|
722
|
+
no_reset = line1 + [p[1] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
|
|
723
|
+
candidate = " | ".join(no_reset)
|
|
724
|
+
if vlen(candidate) <= width:
|
|
725
|
+
line1 = no_reset
|
|
726
|
+
else:
|
|
727
|
+
line1 = line1 + [p[2] for p in rl_parts] + (ctx_parts[1:2] if ctx_parts else [])
|
|
728
|
+
|
|
729
|
+
cost = _as_dict(data.get("cost"))
|
|
730
|
+
|
|
731
|
+
line3: List[str] = []
|
|
732
|
+
duration_ms = cost.get("total_duration_ms")
|
|
733
|
+
duration_part = ""
|
|
734
|
+
if duration_ms and duration_ms > 0:
|
|
735
|
+
duration_part = (
|
|
736
|
+
f"{C['dim']}{C['magenta']}{_t('session_dur')} "
|
|
737
|
+
f"{fmt_duration(float(duration_ms) / 1000)}{C['reset']}"
|
|
738
|
+
)
|
|
739
|
+
line3.append(duration_part)
|
|
740
|
+
|
|
741
|
+
model_name = _as_dict(data.get("model")).get("display_name", "")
|
|
742
|
+
if isinstance(model_name, str) and model_name:
|
|
743
|
+
effort = _as_dict(data.get("effort")).get("level", "")
|
|
744
|
+
if effort:
|
|
745
|
+
effort_label = {
|
|
746
|
+
"xhigh": _t("effort_xhigh"),
|
|
747
|
+
"high": _t("effort_high"),
|
|
748
|
+
"normal": _t("effort_normal"),
|
|
749
|
+
"low": _t("effort_low"),
|
|
750
|
+
}.get(effort, effort)
|
|
751
|
+
model_name += f"/{effort_label}"
|
|
752
|
+
if data.get("fast_mode"):
|
|
753
|
+
model_name += f" {_t('fast_mode')}"
|
|
754
|
+
line3.append(f"{C['dim']}{C['magenta']}{safe_text(model_name)}{C['reset']}")
|
|
755
|
+
|
|
756
|
+
if vlen(" | ".join(line3)) > width and duration_part:
|
|
757
|
+
line3 = [p for p in line3 if p != duration_part]
|
|
758
|
+
|
|
759
|
+
update_version = _read_update_hint(now.timestamp())
|
|
760
|
+
if update_version and (line1 or line3):
|
|
761
|
+
line3.append(
|
|
762
|
+
f"{C['cyan']}🆕 v{safe_text(update_version)} "
|
|
763
|
+
f"{_t('update_available_suffix')}{C['reset']}"
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
output = [" | ".join(line) for line in (line1, line3) if line]
|
|
767
|
+
warning = _heavy_warning(data, now.timestamp())
|
|
768
|
+
if warning:
|
|
769
|
+
output.append(warning)
|
|
770
|
+
return "\n".join(output) if output else "usage"
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def render(data: Dict[str, Any], now: datetime) -> str:
|
|
774
|
+
try:
|
|
775
|
+
return _render_core(data, now)
|
|
776
|
+
except Exception as exc:
|
|
777
|
+
_debug("render failed", exc)
|
|
778
|
+
return "usage"
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def main() -> None:
|
|
782
|
+
_configure_windows_utf8_output()
|
|
783
|
+
try:
|
|
784
|
+
raw = _read_stdin_utf8()
|
|
785
|
+
except Exception as exc:
|
|
786
|
+
_debug("stdin read failed", exc)
|
|
787
|
+
return
|
|
788
|
+
if not raw.strip():
|
|
789
|
+
return
|
|
790
|
+
try:
|
|
791
|
+
data = json.loads(raw)
|
|
792
|
+
except json.JSONDecodeError as exc:
|
|
793
|
+
_debug("invalid stdin JSON", exc)
|
|
794
|
+
print("usage")
|
|
795
|
+
return
|
|
796
|
+
if not isinstance(data, dict):
|
|
797
|
+
_debug("stdin JSON root is not an object")
|
|
798
|
+
print("usage")
|
|
799
|
+
return
|
|
800
|
+
now = datetime.now(timezone.utc)
|
|
801
|
+
try:
|
|
802
|
+
save(data, now)
|
|
803
|
+
print(render(data, now))
|
|
804
|
+
except Exception as exc:
|
|
805
|
+
_debug("statusline failed", exc)
|
|
806
|
+
print("usage")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
if __name__ == "__main__":
|
|
810
|
+
main()
|