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
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
|