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
setup_hook.py ADDED
@@ -0,0 +1,1011 @@
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
+ """Install or remove usage's statusLine hook for Claude Code.
8
+
9
+ Claude Code calls the command configured in ~/.claude/settings.json statusLine
10
+ and sends session JSON on stdin whenever it refreshes the status line. The
11
+ installer copies usage_statusline.py to ~/.claude/usage-statusline.py and points
12
+ statusLine at it, so the main app can read a local status file.
13
+
14
+ The previous statusLine is backed up under settings["usage"]["previousStatusLine"]
15
+ and restored by unsetup.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import ctypes
22
+ import json
23
+ import os
24
+ import re
25
+ import shlex
26
+ import shutil
27
+ import stat
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import tomllib
32
+ from ctypes import wintypes
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Any, cast
36
+
37
+ from codex_paths import codex_home
38
+ from i18n import t as _t
39
+
40
+ CLAUDE_SETTINGS = Path(os.path.expanduser("~/.claude/settings.json"))
41
+ HOOK_TARGET = Path(os.path.expanduser("~/.claude/usage-statusline.py"))
42
+ FORWARDER_TARGET = Path(os.path.expanduser("~/.claude/usage-statusline-forwarder.py"))
43
+ STATUS_FILE = Path(os.path.expanduser("~/.claude/usage-status.json"))
44
+ AGY_SETTINGS = Path(os.path.expanduser("~/.gemini/antigravity-cli/settings.json"))
45
+ AGY_HOOK_TARGET = Path(
46
+ os.path.expanduser("~/.gemini/antigravity-cli/usage-statusline-agy.py")
47
+ )
48
+ AGY_PREVIOUS_STATUSLINE = Path(
49
+ os.path.expanduser("~/.gemini/antigravity-cli/usage-previous-statusline.json")
50
+ )
51
+ CODEX_CONFIG = codex_home() / "config.toml"
52
+ CODEX_BACKUP = codex_home() / "usage-backup.json"
53
+ # LEGACY_TT_* / tokenTracker / tt-* below are MIGRATION-ONLY constants for users
54
+ # upgrading from the third-party tool stormzhang/token-tracker. They are NOT links
55
+ # to any in-repo module or external directory. Do not investigate or "go look" for
56
+ # a token-tracker source. It does not exist in this repository or on this machine.
57
+ LEGACY_CODEX_BACKUP = codex_home() / "tt-backup.json"
58
+ CODEX_STATUS_LINE = [
59
+ "project",
60
+ "git-branch",
61
+ "five-hour-limit",
62
+ "weekly-limit",
63
+ "context-remaining",
64
+ "used-tokens",
65
+ "model-with-reasoning",
66
+ ]
67
+ LEGACY_CODEX_STATUS_LINES: list[list[str]] = [
68
+ [
69
+ "project",
70
+ "five-hour-limit",
71
+ "weekly-limit",
72
+ "context-remaining",
73
+ "model-with-reasoning",
74
+ ]
75
+ ]
76
+ LEGACY_NAME = "usag"
77
+ LEGACY_HOOK_TARGET = Path(os.path.expanduser(f"~/.claude/{LEGACY_NAME}-statusline.py"))
78
+ LEGACY_STATUS_FILE = Path(os.path.expanduser(f"~/.claude/{LEGACY_NAME}-status.json"))
79
+ LEGACY_TT_HOOK_TARGET = Path(os.path.expanduser("~/.claude/tt-statusline.py"))
80
+ BACKUP_KEY = "usage"
81
+ LEGACY_TT_BACKUP_KEY = "tokenTracker"
82
+ LEGACY_BACKUP_KEY = LEGACY_NAME
83
+ PREV_SL_KEY = "previousStatusLine"
84
+ HOOK_VERSION = "1.1"
85
+ # Antigravity's Go runner hands its status-line command to cmd.exe unquoted, so
86
+ # every character cmd.exe treats specially has to be kept out of the path.
87
+ _CMD_UNSAFE_CHARACTERS = '"&|^<>()'
88
+ _SL_REGEX = re.compile(r"(?m)^[ \t]*status_line\s*=\s*\[.*?\]", re.DOTALL)
89
+ _TABLE_REGEX = re.compile(r"(?m)^[ \t]*\[[^\]\n]+\][ \t]*(?:#.*)?$")
90
+
91
+
92
+ def configure_windows_utf8_output() -> None:
93
+ """Prevent Windows legacy console encodings from rejecting CLI messages."""
94
+ if os.name != "nt":
95
+ return
96
+ for stream in (sys.stdout, sys.stderr):
97
+ with contextlib.suppress(AttributeError, OSError, ValueError):
98
+ # Test runners and embedders may replace the TextIOWrapper streams.
99
+ cast(Any, stream).reconfigure(encoding="utf-8")
100
+
101
+
102
+ def _resolve_hook_source() -> Path:
103
+ paths = [
104
+ Path(__file__).resolve().parent / "usage_statusline.py",
105
+ Path(sys.executable).resolve().parent.parent / "Resources" / "usage_statusline.py",
106
+ ]
107
+ for path in paths:
108
+ if path.exists():
109
+ return path
110
+ tried = ", ".join(str(path) for path in paths)
111
+ raise SystemExit(_t("setup_hook_source_missing", tried=tried))
112
+
113
+
114
+ def _resolve_forwarder_source() -> Path:
115
+ paths = [
116
+ Path(__file__).resolve().parent / "usage_statusline_forwarder.py",
117
+ (
118
+ Path(sys.executable).resolve().parent.parent
119
+ / "Resources"
120
+ / "usage_statusline_forwarder.py"
121
+ ),
122
+ ]
123
+ for path in paths:
124
+ if path.exists():
125
+ return path
126
+ tried = ", ".join(str(path) for path in paths)
127
+ raise SystemExit(_t("setup_forwarder_source_missing", tried=tried))
128
+
129
+
130
+ def _resolve_agy_hook_source() -> Path | None:
131
+ paths = [
132
+ Path(__file__).resolve().parent / "usage_statusline_agy.py",
133
+ Path(sys.executable).resolve().parent.parent / "Resources" / "usage_statusline_agy.py",
134
+ ]
135
+ return next((path for path in paths if path.exists()), None)
136
+
137
+
138
+ def _agy_hook_script_is_stale() -> bool:
139
+ """Return whether the deployed Antigravity hook differs from its source."""
140
+ source = _resolve_agy_hook_source()
141
+ if source is None:
142
+ return False
143
+ if not AGY_HOOK_TARGET.is_file():
144
+ return True
145
+ return AGY_HOOK_TARGET.read_bytes() != source.read_bytes()
146
+
147
+
148
+ def _statusline_command() -> str:
149
+ # Prefer a standalone system python, not a venv; the hook is stdlib-only.
150
+ python = _find_system_python()
151
+ return f"{_shell_arg(python)} {_shell_arg(str(HOOK_TARGET))}"
152
+
153
+
154
+ def _statusline_command_target_exists() -> bool:
155
+ settings = _load_settings()
156
+ sl = settings.get("statusLine")
157
+ if not isinstance(sl, dict):
158
+ return True
159
+ command = sl.get("command")
160
+ if not isinstance(command, str):
161
+ return True
162
+ try:
163
+ parts = shlex.split(command, posix=sys.platform != "win32")
164
+ except ValueError:
165
+ return True
166
+ for part in parts:
167
+ part = part.strip('"')
168
+ if "statusline" not in part or not part.endswith(".py"):
169
+ continue
170
+ return Path(os.path.expanduser(part)).exists()
171
+ return True
172
+
173
+
174
+ def _is_working_python(path: str) -> bool:
175
+ """Return whether ``path`` can run as a Python interpreter."""
176
+ try:
177
+ result = subprocess.run([path, "--version"], capture_output=True, timeout=3)
178
+ except (OSError, subprocess.TimeoutExpired):
179
+ return False
180
+ return result.returncode == 0
181
+
182
+
183
+ def _find_system_python() -> str:
184
+ if sys.platform == "win32":
185
+ executable = sys.executable
186
+ if (
187
+ executable
188
+ and not getattr(sys, "frozen", False)
189
+ and _is_ascii_path(executable)
190
+ ):
191
+ return executable
192
+ # Claude Code can fail to spawn a command containing non-ASCII paths
193
+ # on Windows. The hook is stdlib-only, so an ASCII-path Python from
194
+ # PATH (or the Windows launcher) is preferable to this app's venv.
195
+ for candidate in (shutil.which("python"), shutil.which("py")):
196
+ if candidate and _is_ascii_path(candidate) and _is_working_python(candidate):
197
+ return candidate
198
+ raise SystemExit(_t("setup_windows_python_missing"))
199
+ if os.path.exists("/usr/bin/python3"):
200
+ return "/usr/bin/python3"
201
+ executable = sys.executable
202
+ if ".app/Contents" not in executable:
203
+ return executable
204
+ return shutil.which("python3") or "python3"
205
+
206
+
207
+ def _is_ascii_path(value: str) -> bool:
208
+ return value.isascii()
209
+
210
+
211
+ def _shell_arg(value: str) -> str:
212
+ if sys.platform == "win32":
213
+ # Claude Code runs statusLine commands through Git Bash when it is
214
+ # installed. Backslashes are escape characters there, while forward
215
+ # slashes also work in PowerShell, so emit the portable Windows form.
216
+ value = value.replace("\\", "/")
217
+ if not _is_ascii_path(value):
218
+ raise SystemExit(_t("setup_windows_non_ascii_command_path", path=value))
219
+ return subprocess.list2cmdline([value])
220
+ return shlex.quote(value)
221
+
222
+
223
+ def _get_windows_short_path(value: str) -> str:
224
+ """Return ``value`` in its existing Win32 8.3 short-path form."""
225
+ library_name = "WinDLL"
226
+ win_dll: Any = getattr(ctypes, library_name)
227
+ get_short_path_name = win_dll("kernel32", use_last_error=True).GetShortPathNameW
228
+ get_short_path_name.argtypes = (
229
+ wintypes.LPCWSTR,
230
+ wintypes.LPWSTR,
231
+ wintypes.DWORD,
232
+ )
233
+ get_short_path_name.restype = wintypes.DWORD
234
+
235
+ size = 260
236
+ while True:
237
+ buffer = ctypes.create_unicode_buffer(size)
238
+ written = get_short_path_name(value, buffer, size)
239
+ if written == 0:
240
+ function_name = "get_last_error"
241
+ get_last_error: Any = getattr(ctypes, function_name)
242
+ error_code = get_last_error()
243
+ raise OSError(
244
+ error_code,
245
+ f"GetShortPathNameW failed for {value!r}",
246
+ value,
247
+ )
248
+ if written < size:
249
+ return buffer.value
250
+ size = written + 1
251
+
252
+
253
+ def _cmd_unsafe_reason(value: str) -> str | None:
254
+ """Say why ``value`` cannot sit unquoted in a cmd.exe command line."""
255
+ problems = []
256
+ if " " in value:
257
+ problems.append("spaces")
258
+ unsafe = sorted({char for char in value if char in _CMD_UNSAFE_CHARACTERS})
259
+ if unsafe:
260
+ problems.append(
261
+ "unsafe cmd.exe characters " + ", ".join(repr(char) for char in unsafe)
262
+ )
263
+ return " and ".join(problems) or None
264
+
265
+
266
+ def _agy_windows_command_path(value: str, description: str) -> str:
267
+ """Make a path safe for Antigravity's cmd.exe status-line runner."""
268
+ value = value.replace("/", "\\")
269
+ reason = _cmd_unsafe_reason(value)
270
+ if reason is None:
271
+ return value
272
+
273
+ try:
274
+ short_path = _get_windows_short_path(value).replace("/", "\\")
275
+ except OSError as exc:
276
+ raise RuntimeError(
277
+ f"Antigravity cannot use the {description} because its path contains {reason} "
278
+ f"and Windows could not obtain an 8.3 short path for {value!r}. Ensure the "
279
+ "file exists and enable or create 8.3 short names for this volume/path, or "
280
+ "move it to a path without spaces or unsafe cmd.exe characters, then retry."
281
+ ) from exc
282
+
283
+ short_reason = _cmd_unsafe_reason(short_path)
284
+ if short_reason is not None:
285
+ raise RuntimeError(
286
+ f"Antigravity cannot use the {description} because its path contains {reason} "
287
+ f"and Windows returned no usable 8.3 short path: {short_path!r} still contains "
288
+ f"{short_reason}. Enable or create 8.3 short names for this volume/path, or "
289
+ "move it to a path without spaces or unsafe cmd.exe characters, then retry."
290
+ )
291
+ return short_path
292
+
293
+
294
+ def _find_agy_python() -> str:
295
+ """Prefer a working space-free interpreter for Antigravity on Windows."""
296
+ python = _find_system_python()
297
+ if sys.platform != "win32" or " " not in python:
298
+ return python
299
+ for name in ("python", "py"):
300
+ candidate = shutil.which(name)
301
+ if (
302
+ candidate
303
+ and " " not in candidate
304
+ and _is_ascii_path(candidate)
305
+ and _is_working_python(candidate)
306
+ ):
307
+ return candidate
308
+ return python
309
+
310
+
311
+ def _forwarder_command() -> str:
312
+ python = _find_system_python()
313
+ return f"{_shell_arg(python)} {_shell_arg(str(FORWARDER_TARGET))}"
314
+
315
+
316
+ def _uses_bundled_app_python(command: str) -> bool:
317
+ return ".app/Contents" in command
318
+
319
+
320
+ def _migrate_windows_statusline_command_if_needed(
321
+ settings: dict[str, Any] | None = None,
322
+ ) -> None:
323
+ """Repair Windows usage commands with backslash or non-ASCII paths."""
324
+ if sys.platform != "win32":
325
+ return
326
+ data = _load_settings() if settings is None else settings
327
+ sl = data.get("statusLine")
328
+ if not isinstance(sl, dict):
329
+ return
330
+ command = sl.get("command")
331
+ if not isinstance(command, str) or ("\\" not in command and command.isascii()):
332
+ return
333
+ if "usage-statusline-forwarder" in command:
334
+ new_command = _forwarder_command()
335
+ elif "usage-statusline" in command:
336
+ new_command = _statusline_command()
337
+ else:
338
+ return
339
+ if command == new_command:
340
+ return
341
+ sl["command"] = new_command
342
+ _save_settings(data)
343
+ _append_hook_repair_log(
344
+ "migrate_windows_statusline", "backslash/non-ASCII paths -> ASCII forward-slash command"
345
+ )
346
+
347
+
348
+ def _append_hook_repair_log(action: str, detail: str) -> None:
349
+ settings = _load_settings()
350
+ usage_settings = settings.get(BACKUP_KEY)
351
+ if not isinstance(usage_settings, dict):
352
+ usage_settings = {}
353
+ settings[BACKUP_KEY] = usage_settings
354
+ log = usage_settings.get("selfHealLog")
355
+ if not isinstance(log, list):
356
+ log = []
357
+ log.append(
358
+ {
359
+ "timestamp": (
360
+ datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
361
+ ),
362
+ "action": action,
363
+ "detail": detail,
364
+ }
365
+ )
366
+ usage_settings["selfHealLog"] = log[-20:]
367
+ _save_settings(settings)
368
+
369
+
370
+ def _migrate_bundled_python_commands_if_needed(
371
+ settings: dict[str, Any] | None = None,
372
+ ) -> None:
373
+ data = _load_settings() if settings is None else settings
374
+ changed = False
375
+ details: list[str] = []
376
+
377
+ sl = data.get("statusLine")
378
+ if isinstance(sl, dict):
379
+ command = sl.get("command")
380
+ if isinstance(command, str) and _uses_bundled_app_python(command):
381
+ if "usage-statusline-forwarder" in command:
382
+ new_command = _forwarder_command()
383
+ if command != new_command:
384
+ sl["command"] = new_command
385
+ changed = True
386
+ details.append("statusLine=forwarder")
387
+ elif "usage-statusline" in command:
388
+ new_command = _statusline_command()
389
+ if command != new_command:
390
+ sl["command"] = new_command
391
+ changed = True
392
+ details.append("statusLine=direct")
393
+
394
+ if not changed:
395
+ return
396
+ _save_settings(data)
397
+ _append_hook_repair_log("migrate_bundled_python", ", ".join(details))
398
+
399
+
400
+ def _is_usage_hook(sl: object) -> bool:
401
+ if not isinstance(sl, dict):
402
+ return False
403
+ cmd = sl.get("command")
404
+ return isinstance(cmd, str) and "usage-statusline" in cmd
405
+
406
+
407
+ def _is_legacy_tt_hook(sl: object) -> bool:
408
+ if not isinstance(sl, dict):
409
+ return False
410
+ cmd = sl.get("command")
411
+ return isinstance(cmd, str) and "tt-statusline" in cmd
412
+
413
+
414
+ def _detect_current_state(settings: dict[str, Any] | None = None) -> str:
415
+ """Return 'none' | 'us-direct' | 'us-forwarder' | 'legacy-tt' | 'external'."""
416
+ data = _load_settings() if settings is None else settings
417
+ sl = data.get("statusLine")
418
+ if not isinstance(sl, dict):
419
+ return "none"
420
+ cmd = sl.get("command")
421
+ if not isinstance(cmd, str) or not cmd.strip():
422
+ return "none"
423
+ if "usage-statusline-forwarder" in cmd:
424
+ return "us-forwarder"
425
+ if "usage-statusline" in cmd:
426
+ return "us-direct"
427
+ if "tt-statusline" in cmd:
428
+ return "legacy-tt"
429
+ return "external"
430
+
431
+
432
+ def current_hook_state() -> str:
433
+ """Return the installed Claude statusLine hook state."""
434
+ return _detect_current_state()
435
+
436
+
437
+ def _migrate_from_legacy_usage() -> None:
438
+ changed = False
439
+
440
+ for path in (LEGACY_HOOK_TARGET, LEGACY_STATUS_FILE):
441
+ try:
442
+ if path.exists():
443
+ path.unlink()
444
+ changed = True
445
+ except OSError as exc:
446
+ print(_t("setup_legacy_file_remove_failed", path=path, error=exc))
447
+
448
+ settings: dict[str, Any] | None = None
449
+ try:
450
+ if CLAUDE_SETTINGS.exists():
451
+ with CLAUDE_SETTINGS.open(encoding="utf-8") as f:
452
+ data = json.load(f)
453
+ if isinstance(data, dict):
454
+ settings = data
455
+ else:
456
+ print(_t("setup_legacy_settings_not_object", path=CLAUDE_SETTINGS))
457
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
458
+ print(_t("setup_legacy_settings_read_failed", error=exc))
459
+
460
+ if settings is not None:
461
+ try:
462
+ sl = settings.get("statusLine")
463
+ cmd = sl.get("command") if isinstance(sl, dict) else None
464
+ if (
465
+ isinstance(cmd, str)
466
+ and f"{LEGACY_NAME}-statusline" in cmd
467
+ and "usage-statusline" not in cmd
468
+ ):
469
+ settings.pop("statusLine", None)
470
+ changed = True
471
+ except Exception as exc:
472
+ print(_t("setup_legacy_statusline_cleanup_failed", error=exc))
473
+
474
+ try:
475
+ legacy_backup = settings.pop(LEGACY_BACKUP_KEY, None)
476
+ legacy_tt_backup = settings.pop(LEGACY_TT_BACKUP_KEY, None)
477
+ current_backup = settings.get(BACKUP_KEY)
478
+ merged: dict[str, Any] = {}
479
+ if isinstance(legacy_backup, dict):
480
+ merged.update(legacy_backup)
481
+ if isinstance(legacy_tt_backup, dict):
482
+ merged.update(legacy_tt_backup)
483
+ if isinstance(merged, dict) and merged:
484
+ if isinstance(current_backup, dict):
485
+ settings[BACKUP_KEY] = {**merged, **current_backup}
486
+ else:
487
+ settings[BACKUP_KEY] = merged
488
+ changed = True
489
+ elif legacy_backup is not None or legacy_tt_backup is not None:
490
+ changed = True
491
+ except Exception as exc:
492
+ print(_t("setup_legacy_backup_migrate_failed", error=exc))
493
+
494
+ if changed:
495
+ try:
496
+ _save_settings(settings)
497
+ except Exception as exc:
498
+ print(_t("setup_legacy_settings_write_failed", error=exc))
499
+
500
+ if changed:
501
+ print(_t("setup_legacy_migrated", name=LEGACY_NAME))
502
+
503
+
504
+ def _load_settings() -> dict[str, Any]:
505
+ if not CLAUDE_SETTINGS.exists():
506
+ return {}
507
+ try:
508
+ with CLAUDE_SETTINGS.open(encoding="utf-8") as f:
509
+ data = json.load(f)
510
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
511
+ raise SystemExit(_t("setup_settings_read_failed", path=CLAUDE_SETTINGS, error=exc)) from exc
512
+ if not isinstance(data, dict):
513
+ raise SystemExit(_t("setup_settings_not_object", path=CLAUDE_SETTINGS))
514
+ return data
515
+
516
+
517
+ def _atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
518
+ resolved_path = path.resolve()
519
+ resolved_path.parent.mkdir(parents=True, exist_ok=True)
520
+ tmp_path: str | None = None
521
+ try:
522
+ fd, tmp_path = tempfile.mkstemp(dir=resolved_path.parent, suffix=".tmp")
523
+ with os.fdopen(fd, "w", encoding=encoding) as f:
524
+ f.write(content)
525
+ os.replace(tmp_path, resolved_path)
526
+ tmp_path = None
527
+ finally:
528
+ if tmp_path and os.path.exists(tmp_path):
529
+ with contextlib.suppress(OSError):
530
+ os.unlink(tmp_path)
531
+
532
+
533
+ def _save_settings(data: dict[str, Any]) -> None:
534
+ payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
535
+ _atomic_write_text(CLAUDE_SETTINGS, payload)
536
+
537
+
538
+ def _agy_statusline_command() -> str:
539
+ python = _find_agy_python() if sys.platform == "win32" else "/usr/bin/python3"
540
+ if sys.platform == "win32":
541
+ python = _agy_windows_command_path(python, "Python interpreter")
542
+ hook_target = _agy_windows_command_path(
543
+ str(AGY_HOOK_TARGET), "status-line hook"
544
+ )
545
+ return f"{python} {hook_target}"
546
+ return f"{_shell_arg(python)} {_shell_arg(str(AGY_HOOK_TARGET))}"
547
+
548
+
549
+ def _is_agy_usage_hook(status_line: object) -> bool:
550
+ if not isinstance(status_line, dict):
551
+ return False
552
+ command = status_line.get("command")
553
+ return isinstance(command, str) and "usage-statusline-agy.py" in command
554
+
555
+
556
+ def _load_agy_json(path: Path) -> object | None:
557
+ try:
558
+ return cast(object, json.loads(path.read_text(encoding="utf-8")))
559
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
560
+ return None
561
+
562
+
563
+ def _setup_agy() -> bool:
564
+ """Install Antigravity's status line without creating an absent settings file."""
565
+ if sys.platform not in {"darwin", "win32"}:
566
+ return False
567
+ if not AGY_SETTINGS.is_file():
568
+ return False
569
+ settings = _load_agy_json(AGY_SETTINGS)
570
+ if not isinstance(settings, dict):
571
+ return False
572
+ source = _resolve_agy_hook_source()
573
+ if source is None:
574
+ return False
575
+
576
+ existing = settings.get("statusLine")
577
+ if AGY_PREVIOUS_STATUSLINE.exists():
578
+ if _load_agy_json(AGY_PREVIOUS_STATUSLINE) is None:
579
+ return False
580
+ elif existing is not None and not _is_agy_usage_hook(existing):
581
+ try:
582
+ _atomic_write_text(
583
+ AGY_PREVIOUS_STATUSLINE,
584
+ json.dumps(existing, indent=2, ensure_ascii=False) + "\n",
585
+ )
586
+ except OSError:
587
+ return False
588
+
589
+ try:
590
+ AGY_HOOK_TARGET.parent.mkdir(parents=True, exist_ok=True)
591
+ shutil.copyfile(source, AGY_HOOK_TARGET)
592
+ AGY_HOOK_TARGET.chmod(
593
+ AGY_HOOK_TARGET.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
594
+ )
595
+ settings["statusLine"] = {
596
+ "type": "command",
597
+ "command": _agy_statusline_command(),
598
+ "enabled": True,
599
+ }
600
+ _atomic_write_text(
601
+ AGY_SETTINGS,
602
+ json.dumps(settings, indent=2, ensure_ascii=False) + "\n",
603
+ )
604
+ except OSError:
605
+ return False
606
+ return True
607
+
608
+
609
+ def _unsetup_agy() -> bool:
610
+ """Remove usage's Antigravity status line and restore its sidecar backup."""
611
+ if sys.platform not in {"darwin", "win32"}:
612
+ return False
613
+ if not AGY_SETTINGS.is_file():
614
+ return False
615
+ settings = _load_agy_json(AGY_SETTINGS)
616
+ if not isinstance(settings, dict):
617
+ return False
618
+ if not _is_agy_usage_hook(settings.get("statusLine")):
619
+ return False
620
+
621
+ previous: object | None = None
622
+ if AGY_PREVIOUS_STATUSLINE.exists():
623
+ previous = _load_agy_json(AGY_PREVIOUS_STATUSLINE)
624
+ if previous is None:
625
+ return False
626
+ settings["statusLine"] = previous
627
+ else:
628
+ settings.pop("statusLine", None)
629
+
630
+ try:
631
+ _atomic_write_text(
632
+ AGY_SETTINGS,
633
+ json.dumps(settings, indent=2, ensure_ascii=False) + "\n",
634
+ )
635
+ AGY_PREVIOUS_STATUSLINE.unlink(missing_ok=True)
636
+ except OSError:
637
+ return False
638
+ # AGY_HOOK_TARGET is deliberately left in place. Antigravity reads its
639
+ # settings once at startup, so deleting the script races with any CLI that
640
+ # is launching, and that session shows "Statusline Error: No such file"
641
+ # for its whole lifetime. An unreferenced copy costs nothing.
642
+ return True
643
+
644
+
645
+ def is_agy_setup() -> bool:
646
+ """Return whether usage's Antigravity status line is fully installed."""
647
+ if sys.platform not in {"darwin", "win32"}:
648
+ return False
649
+ if not AGY_SETTINGS.is_file() or not AGY_HOOK_TARGET.is_file():
650
+ return False
651
+ settings = _load_agy_json(AGY_SETTINGS)
652
+ if not isinstance(settings, dict):
653
+ return False
654
+ status_line = settings.get("statusLine")
655
+ return (
656
+ isinstance(status_line, dict)
657
+ and status_line.get("type") == "command"
658
+ and status_line.get("command") == _agy_statusline_command()
659
+ and status_line.get("enabled") is True
660
+ )
661
+
662
+
663
+ def _copy_hook_script() -> None:
664
+ hook_source = _resolve_hook_source()
665
+ HOOK_TARGET.parent.mkdir(parents=True, exist_ok=True)
666
+ shutil.copyfile(hook_source, HOOK_TARGET)
667
+ HOOK_TARGET.chmod(HOOK_TARGET.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
668
+
669
+
670
+ def _copy_forwarder_script() -> None:
671
+ forwarder_source = _resolve_forwarder_source()
672
+ FORWARDER_TARGET.parent.mkdir(parents=True, exist_ok=True)
673
+ shutil.copyfile(forwarder_source, FORWARDER_TARGET)
674
+ FORWARDER_TARGET.chmod(
675
+ FORWARDER_TARGET.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
676
+ )
677
+
678
+
679
+ def _backup_existing_statusline(settings: dict[str, Any]) -> None:
680
+ existing = settings.get("statusLine")
681
+ if not existing or _is_usage_hook(existing):
682
+ return
683
+ backup = settings.get(BACKUP_KEY)
684
+ if not isinstance(backup, dict):
685
+ backup = {}
686
+ settings[BACKUP_KEY] = backup
687
+ if PREV_SL_KEY not in backup:
688
+ backup[PREV_SL_KEY] = existing
689
+ print(_t("setup_statusline_backed_up", backup_key=BACKUP_KEY, prev_key=PREV_SL_KEY))
690
+
691
+
692
+ def _status_line_toml(items: list[str]) -> str:
693
+ if not items:
694
+ return "status_line = []"
695
+ body = ",\n".join(f" {json.dumps(item, ensure_ascii=False)}" for item in items)
696
+ return f"status_line = [\n{body},\n]"
697
+
698
+
699
+ def _find_table(content: str, name: str) -> re.Match[str] | None:
700
+ return re.compile(rf"(?m)^[ \t]*\[{re.escape(name)}\][ \t]*(?:#.*)?$").search(content)
701
+
702
+
703
+ def _table_section(content: str, table: re.Match[str]) -> tuple[int, int]:
704
+ """Return (start, end) offsets of ``table``'s body — from end of its header line up to
705
+ (not including) the next top-level table header, or EOF."""
706
+ next_table = _TABLE_REGEX.search(content[table.end() :])
707
+ section_end = len(content) if next_table is None else table.end() + next_table.start()
708
+ return table.end(), section_end
709
+
710
+
711
+ def _insert_table_line(content: str, name: str, line: str) -> str:
712
+ table = _find_table(content, name)
713
+ if table is None:
714
+ return content
715
+ return content[: table.end()] + f"\n{line}" + content[table.end() :]
716
+
717
+
718
+ def _replace_table_line(
719
+ content: str, name: str, line_regex: re.Pattern[str], replacement: str
720
+ ) -> str:
721
+ table = _find_table(content, name)
722
+ if table is None:
723
+ return content
724
+ start, end = _table_section(content, table)
725
+ section = content[start:end]
726
+ return content[:start] + line_regex.sub(replacement, section, count=1) + content[end:]
727
+
728
+
729
+ def _remove_table_line(content: str, name: str, line_regex: re.Pattern[str]) -> str:
730
+ table = _find_table(content, name)
731
+ if table is None:
732
+ return content
733
+ start, end = _table_section(content, table)
734
+ section = content[start:end]
735
+ return content[:start] + line_regex.sub("", section, count=1) + content[end:]
736
+
737
+
738
+ def _ensure_table_line(
739
+ content: str, name: str, line_regex: re.Pattern[str], line: str
740
+ ) -> str:
741
+ """Make sure ``line`` is in table ``name``: replace an existing ``line_regex`` match if
742
+ present, else insert ``line`` fresh. Appends a new ``[name]`` table at EOF when the table
743
+ itself is absent."""
744
+ table = _find_table(content, name)
745
+ if table is None:
746
+ return content.rstrip() + f"\n\n[{name}]\n{line}\n"
747
+ start, end = _table_section(content, table)
748
+ section = content[start:end]
749
+ if line_regex.search(section):
750
+ return content[:start] + line_regex.sub(line, section, count=1) + content[end:]
751
+ return content[:start] + f"\n{line}" + content[start:]
752
+
753
+
754
+ def _find_tui_table(content: str) -> re.Match[str] | None:
755
+ return _find_table(content, "tui")
756
+
757
+
758
+ def _insert_tui_status_line(content: str, replacement: str) -> str:
759
+ return _insert_table_line(content, "tui", replacement)
760
+
761
+
762
+ def _replace_tui_status_line(content: str, replacement: str) -> str:
763
+ return _replace_table_line(content, "tui", _SL_REGEX, replacement)
764
+
765
+
766
+ def _remove_tui_status_line(content: str) -> str:
767
+ return _remove_table_line(content, "tui", _SL_REGEX)
768
+
769
+
770
+ def _read_codex_config() -> tuple[str, dict[str, Any]] | None:
771
+ try:
772
+ content = CODEX_CONFIG.read_text(encoding="utf-8")
773
+ parsed = tomllib.loads(content)
774
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
775
+ return None
776
+ return content, parsed
777
+
778
+
779
+ def _codex_status_line(parsed: dict[str, Any]) -> object:
780
+ tui = parsed.get("tui")
781
+ return tui.get("status_line") if isinstance(tui, dict) else None
782
+
783
+
784
+ def _is_our_codex_status_line(value: object) -> bool:
785
+ return value == CODEX_STATUS_LINE or value in LEGACY_CODEX_STATUS_LINES
786
+
787
+
788
+ def _setup_codex() -> None:
789
+ result = _read_codex_config()
790
+ if not result:
791
+ if CODEX_CONFIG.exists():
792
+ print(_t("setup_codex_config_unreadable"))
793
+ return
794
+ content, parsed = result
795
+
796
+ old = _codex_status_line(parsed)
797
+ if old == CODEX_STATUS_LINE:
798
+ print(_t("setup_codex_already_configured"))
799
+ return
800
+
801
+ if old in LEGACY_CODEX_STATUS_LINES:
802
+ content = _replace_tui_status_line(content, _status_line_toml(CODEX_STATUS_LINE))
803
+ elif old is not None:
804
+ CODEX_BACKUP.parent.mkdir(parents=True, exist_ok=True)
805
+ CODEX_BACKUP.write_text(
806
+ json.dumps({"status_line": old}, indent=2, ensure_ascii=False) + "\n",
807
+ encoding="utf-8",
808
+ )
809
+ content = _replace_tui_status_line(content, _status_line_toml(CODEX_STATUS_LINE))
810
+ elif isinstance(parsed.get("tui"), dict):
811
+ content = _insert_tui_status_line(content, _status_line_toml(CODEX_STATUS_LINE))
812
+ else:
813
+ content += f"\n[tui]\n{_status_line_toml(CODEX_STATUS_LINE)}\n"
814
+
815
+ _atomic_write_text(CODEX_CONFIG, content)
816
+ print(_t("setup_codex_configured"))
817
+ if old is not None and old not in LEGACY_CODEX_STATUS_LINES:
818
+ print(_t("setup_codex_backup_written", path=CODEX_BACKUP))
819
+ print(_t("setup_codex_restart_required"))
820
+
821
+
822
+ def _unsetup_codex() -> None:
823
+ result = _read_codex_config()
824
+ if not result:
825
+ return
826
+ content, parsed = result
827
+
828
+ if not _is_our_codex_status_line(_codex_status_line(parsed)):
829
+ print(_t("setup_codex_unsetup_foreign"))
830
+ return
831
+
832
+ backup_path = CODEX_BACKUP if CODEX_BACKUP.exists() else LEGACY_CODEX_BACKUP
833
+ if backup_path.exists():
834
+ try:
835
+ old_items = json.loads(backup_path.read_text(encoding="utf-8")).get("status_line")
836
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, AttributeError):
837
+ print(_t("setup_codex_backup_invalid"))
838
+ return
839
+ if not isinstance(old_items, list):
840
+ print(_t("setup_codex_backup_invalid"))
841
+ return
842
+ content = _replace_tui_status_line(content, _status_line_toml(old_items))
843
+ # Write the restored config before deleting the backup: if the write fails, the
844
+ # backup must survive so a later retry can still recover the original status line.
845
+ _atomic_write_text(CODEX_CONFIG, content)
846
+ backup_path.unlink(missing_ok=True)
847
+ print(_t("setup_codex_restored"))
848
+ else:
849
+ content = _remove_tui_status_line(content)
850
+ _atomic_write_text(CODEX_CONFIG, content)
851
+ print(_t("setup_codex_removed"))
852
+
853
+
854
+ def _installed_hook_version() -> str | None:
855
+ try:
856
+ with HOOK_TARGET.open(encoding="utf-8") as f:
857
+ for line in f:
858
+ if line.startswith("__version__"):
859
+ return line.split("=", 1)[1].strip().strip("\"'")
860
+ except OSError:
861
+ pass
862
+ return None
863
+
864
+
865
+ def needs_update() -> bool:
866
+ if not HOOK_TARGET.parent.exists():
867
+ return False
868
+ return _installed_hook_version() != HOOK_VERSION
869
+
870
+
871
+ def update_hook() -> None:
872
+ if not HOOK_TARGET.parent.exists():
873
+ return
874
+ _copy_hook_script()
875
+
876
+
877
+
878
+
879
+ def is_setup() -> bool:
880
+ has_claude = CLAUDE_SETTINGS.parent.exists()
881
+ has_codex = CODEX_CONFIG.exists()
882
+ if not has_claude and not has_codex:
883
+ return False
884
+
885
+ if has_claude and _detect_current_state() not in {"us-direct", "us-forwarder"}:
886
+ return False
887
+
888
+ if has_codex:
889
+ result = _read_codex_config()
890
+ if not result:
891
+ return False
892
+ _, parsed = result
893
+ if not _is_our_codex_status_line(_codex_status_line(parsed)):
894
+ return False
895
+
896
+ return True
897
+
898
+
899
+ def is_claude_setup() -> bool:
900
+ """Check only whether the Claude hook is installed."""
901
+ if not CLAUDE_SETTINGS.parent.exists():
902
+ return True
903
+ return _detect_current_state() in {"us-direct", "us-forwarder"}
904
+
905
+
906
+ def is_codex_setup() -> bool:
907
+ """Check only whether the Codex hook is installed."""
908
+ if not CODEX_CONFIG.exists():
909
+ return True
910
+ result = _read_codex_config()
911
+ if not result:
912
+ return False
913
+ _, parsed = result
914
+ return _is_our_codex_status_line(_codex_status_line(parsed))
915
+
916
+
917
+ def _install_forwarder(settings: dict[str, Any]) -> None:
918
+ """Copy usage_statusline_forwarder.py to ~/.claude/ and update settings.json."""
919
+ _copy_hook_script()
920
+ _copy_forwarder_script()
921
+ _backup_existing_statusline(settings)
922
+ settings["statusLine"] = {"type": "command", "command": _forwarder_command()}
923
+ _save_settings(settings)
924
+
925
+
926
+ def setup(force_forwarder: bool = False) -> int:
927
+ configure_windows_utf8_output()
928
+ _migrate_from_legacy_usage()
929
+ has_claude = CLAUDE_SETTINGS.parent.exists()
930
+ has_codex = CODEX_CONFIG.exists()
931
+ if not has_claude and not has_codex:
932
+ print(_t("setup_no_agents"), file=sys.stderr)
933
+ return 1
934
+
935
+ if has_claude:
936
+ settings = _load_settings()
937
+ _migrate_bundled_python_commands_if_needed(settings)
938
+ _migrate_windows_statusline_command_if_needed(settings)
939
+ state = _detect_current_state(settings)
940
+
941
+ if force_forwarder or state in {"external", "legacy-tt"}:
942
+ _install_forwarder(settings)
943
+ print(_t("setup_forwarder_installed", path=FORWARDER_TARGET))
944
+ print(_t("setup_hook_installed", path=HOOK_TARGET))
945
+ print(_t("setup_settings_updated", path=CLAUDE_SETTINGS))
946
+ print(_t("setup_claude_restart_required"))
947
+ else:
948
+ _copy_hook_script()
949
+ if state == "none":
950
+ settings["statusLine"] = {"type": "command", "command": _statusline_command()}
951
+ _save_settings(settings)
952
+ elif state in {"us-direct", "us-forwarder"}:
953
+ print(_t("setup_statusline_already_usage"))
954
+
955
+ print(_t("setup_hook_installed", path=HOOK_TARGET))
956
+ print(_t("setup_settings_updated", path=CLAUDE_SETTINGS))
957
+ print(_t("setup_claude_restart_required"))
958
+
959
+ if has_codex:
960
+ _setup_codex()
961
+
962
+ return 0
963
+
964
+
965
+ def unsetup() -> int:
966
+ configure_windows_utf8_output()
967
+ if CLAUDE_SETTINGS.parent.exists():
968
+ settings = _load_settings()
969
+ sl = settings.get("statusLine")
970
+
971
+ if _is_usage_hook(sl) or _is_legacy_tt_hook(sl):
972
+ backup = settings.get(BACKUP_KEY)
973
+ legacy_backup = settings.get(LEGACY_TT_BACKUP_KEY)
974
+ prev = backup.get(PREV_SL_KEY) if isinstance(backup, dict) else None
975
+ if not isinstance(prev, dict) and isinstance(legacy_backup, dict):
976
+ prev = legacy_backup.get(PREV_SL_KEY)
977
+
978
+ if isinstance(prev, dict):
979
+ settings["statusLine"] = prev
980
+ print(_t("setup_claude_statusline_restored"))
981
+ else:
982
+ settings.pop("statusLine", None)
983
+ print(_t("setup_claude_statusline_removed"))
984
+
985
+ if isinstance(backup, dict):
986
+ backup.pop(PREV_SL_KEY, None)
987
+ if not backup:
988
+ del settings[BACKUP_KEY]
989
+ if isinstance(legacy_backup, dict):
990
+ legacy_backup.pop(PREV_SL_KEY, None)
991
+ if not legacy_backup:
992
+ del settings[LEGACY_TT_BACKUP_KEY]
993
+
994
+ _save_settings(settings)
995
+ else:
996
+ print(_t("setup_statusline_not_usage"))
997
+
998
+ for path in (HOOK_TARGET, FORWARDER_TARGET, LEGACY_TT_HOOK_TARGET):
999
+ if path.exists():
1000
+ path.unlink()
1001
+ print(_t("setup_hook_deleted", path=path))
1002
+
1003
+ if STATUS_FILE.exists():
1004
+ STATUS_FILE.unlink()
1005
+ print(_t("setup_status_file_deleted", path=STATUS_FILE))
1006
+
1007
+
1008
+ if CODEX_CONFIG.exists():
1009
+ _unsetup_codex()
1010
+
1011
+ return 0