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_agy.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
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: UP006, UP035, UP045
|
|
9
|
+
"""Render Antigravity CLI's stdin payload as a usage-style status line.
|
|
10
|
+
|
|
11
|
+
This deployed script intentionally uses only the Python standard library and
|
|
12
|
+
does not read or write files other than reading a Git HEAD for the branch name.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from contextlib import suppress
|
|
22
|
+
from typing import Any, Dict, List, Optional, Tuple, cast
|
|
23
|
+
|
|
24
|
+
STATUSLINE_TRANSLATIONS = {
|
|
25
|
+
"zh-TW": {
|
|
26
|
+
"five_hour": "5小時",
|
|
27
|
+
"weekly": "本週",
|
|
28
|
+
"context": "對話窗",
|
|
29
|
+
"remaining_prefix": "剩",
|
|
30
|
+
"effort_high": "深思",
|
|
31
|
+
"effort_normal": "標準",
|
|
32
|
+
"effort_low": "速答",
|
|
33
|
+
},
|
|
34
|
+
"zh-CN": {
|
|
35
|
+
"five_hour": "5小时",
|
|
36
|
+
"weekly": "本周",
|
|
37
|
+
"context": "对话窗",
|
|
38
|
+
"remaining_prefix": "剩",
|
|
39
|
+
"effort_high": "深思",
|
|
40
|
+
"effort_normal": "标准",
|
|
41
|
+
"effort_low": "速答",
|
|
42
|
+
},
|
|
43
|
+
"en": {
|
|
44
|
+
"five_hour": "5h",
|
|
45
|
+
"weekly": "Weekly",
|
|
46
|
+
"context": "Context",
|
|
47
|
+
"remaining_prefix": "left",
|
|
48
|
+
"effort_high": "Deep",
|
|
49
|
+
"effort_normal": "Standard",
|
|
50
|
+
"effort_low": "Quick",
|
|
51
|
+
},
|
|
52
|
+
"ja": {
|
|
53
|
+
"five_hour": "5時間",
|
|
54
|
+
"weekly": "週間",
|
|
55
|
+
"context": "コンテキスト",
|
|
56
|
+
"remaining_prefix": "残り",
|
|
57
|
+
"effort_high": "熟考",
|
|
58
|
+
"effort_normal": "標準",
|
|
59
|
+
"effort_low": "即答",
|
|
60
|
+
},
|
|
61
|
+
"ko": {
|
|
62
|
+
"five_hour": "5시간",
|
|
63
|
+
"weekly": "주간",
|
|
64
|
+
"context": "컨텍스트",
|
|
65
|
+
"remaining_prefix": "남음",
|
|
66
|
+
"effort_high": "깊은 사고",
|
|
67
|
+
"effort_normal": "표준",
|
|
68
|
+
"effort_low": "빠른 답변",
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
C = {
|
|
72
|
+
"green": "\033[38;5;80m",
|
|
73
|
+
"blue": "\033[38;5;39m",
|
|
74
|
+
"magenta": "\033[38;5;111m",
|
|
75
|
+
"dim": "\033[2m",
|
|
76
|
+
"reset": "\033[0m",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _configure_windows_utf8_output() -> None:
|
|
81
|
+
if os.name != "nt":
|
|
82
|
+
return
|
|
83
|
+
for stream in (sys.stdout, sys.stderr):
|
|
84
|
+
with suppress(AttributeError, OSError, ValueError):
|
|
85
|
+
cast(Any, stream).reconfigure(encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_stdin_utf8() -> str:
|
|
89
|
+
buffer = getattr(sys.stdin, "buffer", None)
|
|
90
|
+
if buffer is None:
|
|
91
|
+
return sys.stdin.read()
|
|
92
|
+
return cast(bytes, buffer.read()).decode("utf-8", "replace")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _windows_system_lang() -> str:
|
|
96
|
+
if os.name != "nt":
|
|
97
|
+
return ""
|
|
98
|
+
try:
|
|
99
|
+
import ctypes
|
|
100
|
+
import locale as _locale
|
|
101
|
+
|
|
102
|
+
windll = getattr(ctypes, "windll", None)
|
|
103
|
+
if windll is None:
|
|
104
|
+
return ""
|
|
105
|
+
lang_id = int(windll.kernel32.GetUserDefaultUILanguage())
|
|
106
|
+
return _locale.windows_locale.get(lang_id, "") or ""
|
|
107
|
+
except Exception:
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _statusline_detect_lang(env: Optional[Dict[str, str]] = None) -> str:
|
|
112
|
+
source = os.environ if env is None else env
|
|
113
|
+
raw = ""
|
|
114
|
+
# Windows 上的 LANG 多半是 Git Bash / MSYS 帶進來的,不代表使用者的系統語言。
|
|
115
|
+
keys = (
|
|
116
|
+
("USAGE_LANG", "TT_LANG") if sys.platform == "win32" else ("USAGE_LANG", "TT_LANG", "LANG")
|
|
117
|
+
)
|
|
118
|
+
for key in keys:
|
|
119
|
+
value = source.get(key, "").strip()
|
|
120
|
+
if value:
|
|
121
|
+
raw = value
|
|
122
|
+
break
|
|
123
|
+
if not raw and env is None:
|
|
124
|
+
raw = _windows_system_lang()
|
|
125
|
+
code = raw.split(".")[0].replace("_", "-")
|
|
126
|
+
table = {
|
|
127
|
+
"zh-TW": "zh-TW",
|
|
128
|
+
"zh-HK": "zh-TW",
|
|
129
|
+
"zh-CN": "zh-CN",
|
|
130
|
+
"zh": "zh-CN",
|
|
131
|
+
"ja-JP": "ja",
|
|
132
|
+
"ja": "ja",
|
|
133
|
+
"ko-KR": "ko",
|
|
134
|
+
"ko": "ko",
|
|
135
|
+
}
|
|
136
|
+
return table.get(code, "en")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _detect_lang() -> str:
|
|
140
|
+
return _statusline_detect_lang()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _t(key: str) -> str:
|
|
144
|
+
table = STATUSLINE_TRANSLATIONS.get(_detect_lang(), STATUSLINE_TRANSLATIONS["en"])
|
|
145
|
+
return table.get(key, key)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def vlen(s: str) -> int:
|
|
149
|
+
visible = 0
|
|
150
|
+
i = 0
|
|
151
|
+
while i < len(s):
|
|
152
|
+
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
|
153
|
+
i += 2
|
|
154
|
+
while i < len(s) and s[i] != "m":
|
|
155
|
+
i += 1
|
|
156
|
+
i += 1
|
|
157
|
+
continue
|
|
158
|
+
visible += 1
|
|
159
|
+
i += 1
|
|
160
|
+
return visible
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def color_by_pct(pct: float) -> str:
|
|
164
|
+
if pct < 50:
|
|
165
|
+
return "\033[38;5;42m"
|
|
166
|
+
if pct < 80:
|
|
167
|
+
return "\033[38;5;214m"
|
|
168
|
+
return "\033[38;5;160m"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def progress_bar(value: Any, bar_width: int = 8) -> str:
|
|
172
|
+
filled_char = "■"
|
|
173
|
+
empty_char = "□"
|
|
174
|
+
if value is None:
|
|
175
|
+
return empty_char * bar_width + " n/a"
|
|
176
|
+
pct = max(0.0, min(100.0, float(value)))
|
|
177
|
+
filled = round(pct / 100 * bar_width)
|
|
178
|
+
return (
|
|
179
|
+
f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}"
|
|
180
|
+
f"{empty_char * (bar_width - filled)} "
|
|
181
|
+
f"{color_by_pct(pct)}{pct:.0f}%{C['reset']}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def fmt_duration(seconds: float) -> str:
|
|
186
|
+
if seconds >= 86400:
|
|
187
|
+
days = int(seconds // 86400)
|
|
188
|
+
remainder = int(seconds % 86400)
|
|
189
|
+
return f"{days}d{remainder // 3600}h"
|
|
190
|
+
if seconds >= 3600:
|
|
191
|
+
hours = int(seconds // 3600)
|
|
192
|
+
minutes = int((seconds % 3600) // 60)
|
|
193
|
+
return f"{hours}h{minutes}m"
|
|
194
|
+
if seconds >= 60:
|
|
195
|
+
return f"{int(seconds // 60)}min"
|
|
196
|
+
return f"{int(seconds)}s"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def fmt_tokens(n: Any) -> str:
|
|
200
|
+
try:
|
|
201
|
+
value = int(n)
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
value = 0
|
|
204
|
+
if value >= 1_000_000:
|
|
205
|
+
return f"{value / 1_000_000:.1f}M"
|
|
206
|
+
if value >= 1_000:
|
|
207
|
+
return f"{value / 1_000:.0f}k"
|
|
208
|
+
return str(value)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def safe_text(value: str) -> str:
|
|
212
|
+
"""Drop control characters so untrusted names cannot rewrite the status line."""
|
|
213
|
+
return "".join(ch for ch in value if ch.isprintable())
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def git_branch(cwd: str) -> str:
|
|
217
|
+
path = os.path.abspath(cwd)
|
|
218
|
+
while True:
|
|
219
|
+
git_path = os.path.join(path, ".git")
|
|
220
|
+
if os.path.isdir(git_path):
|
|
221
|
+
head_path = os.path.join(git_path, "HEAD")
|
|
222
|
+
break
|
|
223
|
+
if os.path.isfile(git_path):
|
|
224
|
+
try:
|
|
225
|
+
with open(git_path, encoding="utf-8") as f:
|
|
226
|
+
target = f.read().strip()
|
|
227
|
+
if target.startswith("gitdir:"):
|
|
228
|
+
git_dir = target.split(":", 1)[1].strip()
|
|
229
|
+
if not os.path.isabs(git_dir):
|
|
230
|
+
git_dir = os.path.normpath(os.path.join(path, git_dir))
|
|
231
|
+
head_path = os.path.join(git_dir, "HEAD")
|
|
232
|
+
break
|
|
233
|
+
except OSError:
|
|
234
|
+
return ""
|
|
235
|
+
parent = os.path.dirname(path)
|
|
236
|
+
if parent == path:
|
|
237
|
+
return ""
|
|
238
|
+
path = parent
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
with open(head_path, encoding="utf-8") as f:
|
|
242
|
+
head = f.read().strip()
|
|
243
|
+
except OSError:
|
|
244
|
+
return ""
|
|
245
|
+
prefix = "ref: refs/heads/"
|
|
246
|
+
if head.startswith(prefix):
|
|
247
|
+
return head[len(prefix) :]
|
|
248
|
+
if head:
|
|
249
|
+
return head[:7]
|
|
250
|
+
return ""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _as_dict(value: Any) -> Dict[str, Any]:
|
|
254
|
+
if isinstance(value, dict):
|
|
255
|
+
return value
|
|
256
|
+
return {}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _as_float(value: Any) -> Optional[float]:
|
|
260
|
+
try:
|
|
261
|
+
number = float(value)
|
|
262
|
+
except (TypeError, ValueError):
|
|
263
|
+
return None
|
|
264
|
+
return number if math.isfinite(number) else None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _terminal_width(value: Any) -> int:
|
|
268
|
+
try:
|
|
269
|
+
return max(1, int(value))
|
|
270
|
+
except (TypeError, ValueError):
|
|
271
|
+
return 116
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _quota_keys(data: Dict[str, Any]) -> Tuple[str, str]:
|
|
275
|
+
model_id = _as_dict(data.get("model")).get("id", "")
|
|
276
|
+
if isinstance(model_id, str) and "gemini" in model_id.lower():
|
|
277
|
+
return "gemini-5h", "gemini-weekly"
|
|
278
|
+
return "3p-5h", "3p-weekly"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _quota_parts(data: Dict[str, Any], bar_width: int) -> List[Tuple[str, str, str]]:
|
|
282
|
+
quota = _as_dict(data.get("quota"))
|
|
283
|
+
lang = _detect_lang()
|
|
284
|
+
parts: List[Tuple[str, str, str]] = []
|
|
285
|
+
five_hour_key, weekly_key = _quota_keys(data)
|
|
286
|
+
for key, label in (
|
|
287
|
+
(five_hour_key, _t("five_hour")),
|
|
288
|
+
(weekly_key, _t("weekly")),
|
|
289
|
+
):
|
|
290
|
+
entry = _as_dict(quota.get(key))
|
|
291
|
+
remaining = _as_float(entry.get("remaining_fraction"))
|
|
292
|
+
if remaining is None:
|
|
293
|
+
continue
|
|
294
|
+
pct = max(0.0, min(100.0, (1.0 - remaining) * 100.0))
|
|
295
|
+
reset = _as_float(entry.get("reset_in_seconds"))
|
|
296
|
+
reset_str = ""
|
|
297
|
+
if reset is not None and reset > 0:
|
|
298
|
+
if lang in ("zh-TW", "zh-CN"):
|
|
299
|
+
reset_str = f" ({_t('remaining_prefix')}{fmt_duration(reset)})"
|
|
300
|
+
else:
|
|
301
|
+
reset_str = f" ({fmt_duration(reset)} {_t('remaining_prefix')})"
|
|
302
|
+
parts.append(
|
|
303
|
+
(
|
|
304
|
+
f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_width)}{reset_str}",
|
|
305
|
+
f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_width)}",
|
|
306
|
+
f"{C['blue']}{label}:{C['reset']}{pct:.0f}%",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return parts
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _render_core(data: Dict[str, Any]) -> str:
|
|
313
|
+
width = _terminal_width(data.get("terminal_width"))
|
|
314
|
+
bar_width = 8 if width >= 100 else 6 if width >= 60 else 4
|
|
315
|
+
project_parts: List[str] = []
|
|
316
|
+
workspace = _as_dict(data.get("workspace"))
|
|
317
|
+
project = workspace.get("current_dir") or data.get("cwd")
|
|
318
|
+
if isinstance(project, str) and project:
|
|
319
|
+
name = safe_text(os.path.basename(os.path.normpath(project)))
|
|
320
|
+
branch = safe_text(git_branch(project))
|
|
321
|
+
if branch:
|
|
322
|
+
project_parts.append(
|
|
323
|
+
f"{C['green']}{name}{C['reset']}({C['magenta']}{branch}{C['reset']})"
|
|
324
|
+
)
|
|
325
|
+
elif name:
|
|
326
|
+
project_parts.append(f"{C['green']}{name}{C['reset']}")
|
|
327
|
+
|
|
328
|
+
quota_parts = _quota_parts(data, bar_width)
|
|
329
|
+
context = _as_dict(data.get("context_window"))
|
|
330
|
+
context_pct = _as_float(context.get("used_percentage"))
|
|
331
|
+
context_parts: List[str] = []
|
|
332
|
+
if context_pct is not None:
|
|
333
|
+
context_pct = max(0.0, min(100.0, context_pct))
|
|
334
|
+
context_parts = [
|
|
335
|
+
f"{C['blue']}{_t('context')}:{C['reset']}"
|
|
336
|
+
f"{progress_bar(context_pct, bar_width)} / "
|
|
337
|
+
f"{fmt_tokens(context.get('context_window_size', 0))}",
|
|
338
|
+
f"{C['blue']}{_t('context')}:{C['reset']}{context_pct:.0f}%",
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
model_parts: List[str] = []
|
|
342
|
+
model = _as_dict(data.get("model"))
|
|
343
|
+
model_name = model.get("display_name") or model.get("id")
|
|
344
|
+
if isinstance(model_name, str) and model_name:
|
|
345
|
+
effort = model.get("effort")
|
|
346
|
+
if isinstance(effort, str) and effort:
|
|
347
|
+
effort_label = {
|
|
348
|
+
"low": _t("effort_low"),
|
|
349
|
+
"medium": _t("effort_normal"),
|
|
350
|
+
"high": _t("effort_high"),
|
|
351
|
+
}.get(effort.lower(), effort)
|
|
352
|
+
model_name += f"/{effort_label}"
|
|
353
|
+
model_parts.append(f"{C['dim']}{C['magenta']}{safe_text(model_name)}{C['reset']}")
|
|
354
|
+
|
|
355
|
+
full = project_parts + [part[0] for part in quota_parts] + context_parts[:1] + model_parts
|
|
356
|
+
if vlen(" | ".join(full)) <= width:
|
|
357
|
+
selected = full
|
|
358
|
+
else:
|
|
359
|
+
no_reset = (
|
|
360
|
+
project_parts + [part[1] for part in quota_parts] + context_parts[:1] + model_parts
|
|
361
|
+
)
|
|
362
|
+
if vlen(" | ".join(no_reset)) <= width:
|
|
363
|
+
selected = no_reset
|
|
364
|
+
else:
|
|
365
|
+
selected = (
|
|
366
|
+
project_parts
|
|
367
|
+
+ [part[2] for part in quota_parts]
|
|
368
|
+
+ context_parts[1:2]
|
|
369
|
+
+ model_parts
|
|
370
|
+
)
|
|
371
|
+
return " | ".join(selected) if selected else "usage"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def render(data: Dict[str, Any]) -> str:
|
|
375
|
+
try:
|
|
376
|
+
return _render_core(data)
|
|
377
|
+
except Exception:
|
|
378
|
+
return "usage"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main() -> None:
|
|
382
|
+
_configure_windows_utf8_output()
|
|
383
|
+
try:
|
|
384
|
+
raw = _read_stdin_utf8()
|
|
385
|
+
if not raw.strip():
|
|
386
|
+
return
|
|
387
|
+
data = json.loads(raw)
|
|
388
|
+
if not isinstance(data, dict):
|
|
389
|
+
print("usage")
|
|
390
|
+
return
|
|
391
|
+
print(render(data))
|
|
392
|
+
except Exception:
|
|
393
|
+
print("usage")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
if __name__ == "__main__":
|
|
397
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
"""usage app statusLine forwarder: fan stdin out to ~/.claude/*-statusline.py."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import concurrent.futures
|
|
13
|
+
import contextlib
|
|
14
|
+
import glob
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
|
|
21
|
+
__version__ = "1.0"
|
|
22
|
+
TIMEOUT_SECONDS = 5
|
|
23
|
+
HOOK_DIR = os.path.expanduser("~/.claude")
|
|
24
|
+
SELF_NAME = "usage-statusline-forwarder.py"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _configure_windows_utf8_output() -> None:
|
|
28
|
+
"""Make forwarded hook output UTF-8 when Claude Code reads a pipe."""
|
|
29
|
+
if os.name != "nt":
|
|
30
|
+
return
|
|
31
|
+
for stream in (sys.stdout, sys.stderr):
|
|
32
|
+
with contextlib.suppress(AttributeError, OSError, ValueError):
|
|
33
|
+
# Test runners and embedders may replace the TextIOWrapper streams.
|
|
34
|
+
cast(Any, stream).reconfigure(encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_stdin_utf8() -> str:
|
|
38
|
+
buffer = getattr(sys.stdin, "buffer", None)
|
|
39
|
+
if buffer is None:
|
|
40
|
+
return sys.stdin.read()
|
|
41
|
+
return cast(bytes, buffer.read()).decode("utf-8", "replace")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_hook(py: str, hook: str, raw: str) -> str:
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
[py, hook],
|
|
48
|
+
input=raw,
|
|
49
|
+
text=True,
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
errors="replace",
|
|
52
|
+
check=False,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
timeout=TIMEOUT_SECONDS,
|
|
55
|
+
)
|
|
56
|
+
except (subprocess.TimeoutExpired, OSError, UnicodeDecodeError):
|
|
57
|
+
return ""
|
|
58
|
+
return result.stdout or ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> None:
|
|
62
|
+
_configure_windows_utf8_output()
|
|
63
|
+
raw = _read_stdin_utf8()
|
|
64
|
+
if not raw.strip():
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
hooks: list[str] = []
|
|
68
|
+
for path in sorted(glob.glob(os.path.join(HOOK_DIR, "*-statusline.py"))):
|
|
69
|
+
name = os.path.basename(path)
|
|
70
|
+
if name == SELF_NAME:
|
|
71
|
+
continue
|
|
72
|
+
if "-forwarder" in name:
|
|
73
|
+
continue
|
|
74
|
+
hooks.append(path)
|
|
75
|
+
|
|
76
|
+
py = sys.executable or shutil.which("python") or "python"
|
|
77
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(hooks))) as ex:
|
|
78
|
+
futures = [ex.submit(_run_hook, py, hook, raw) for hook in hooks]
|
|
79
|
+
for future in futures:
|
|
80
|
+
out = future.result()
|
|
81
|
+
if out:
|
|
82
|
+
sys.stdout.write(out)
|
|
83
|
+
|
|
84
|
+
sys.stdout.flush()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|