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
@@ -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()