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
login_item.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import plistlib
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from Foundation import NSBundle
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
LABEL = "com.lollapalooza.usage"
|
|
22
|
+
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
23
|
+
_LOG_DIR = Path.home() / "Library" / "Logs" / "usage"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_plist(program_args: list[str], working_dir: str | None) -> str:
|
|
27
|
+
log_dir = str(_LOG_DIR)
|
|
28
|
+
payload: dict[str, object] = {
|
|
29
|
+
"Label": LABEL,
|
|
30
|
+
"RunAtLoad": True,
|
|
31
|
+
"ProgramArguments": program_args,
|
|
32
|
+
"StandardOutPath": f"{log_dir}/usage.log",
|
|
33
|
+
"StandardErrorPath": f"{log_dir}/usage.err.log",
|
|
34
|
+
"ThrottleInterval": 15,
|
|
35
|
+
"StartDelay": 10,
|
|
36
|
+
"ProcessType": "Interactive",
|
|
37
|
+
}
|
|
38
|
+
if working_dir is None:
|
|
39
|
+
# .app launchd entry uses `open <bundle>`; KeepAlive would re-open endlessly.
|
|
40
|
+
pass
|
|
41
|
+
else:
|
|
42
|
+
payload["KeepAlive"] = {"SuccessfulExit": False}
|
|
43
|
+
payload["WorkingDirectory"] = working_dir
|
|
44
|
+
return plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=False).decode("utf-8")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bundle_value(bundle: Any, attr_name: str) -> str | None:
|
|
48
|
+
attr = getattr(bundle, attr_name, None)
|
|
49
|
+
value = attr() if callable(attr) else attr
|
|
50
|
+
return str(value) if value is not None else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _program_context() -> tuple[list[str], str | None]:
|
|
54
|
+
bundle = NSBundle.mainBundle()
|
|
55
|
+
if _bundle_value(bundle, "bundleIdentifier") == LABEL:
|
|
56
|
+
bundle_path = _bundle_value(bundle, "bundlePath")
|
|
57
|
+
if bundle_path:
|
|
58
|
+
return ["/usr/bin/open", bundle_path], None
|
|
59
|
+
project_dir = Path(__file__).resolve().parent
|
|
60
|
+
main_py = project_dir / "main.py"
|
|
61
|
+
return [sys.executable, str(main_py)], str(project_dir)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_enabled() -> bool:
|
|
65
|
+
return PLIST_PATH.exists()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _plist_text() -> str:
|
|
69
|
+
program_args, working_dir = _program_context()
|
|
70
|
+
return build_plist(program_args, working_dir)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ensure_parent_dirs() -> None:
|
|
74
|
+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_plist(contents: str) -> None:
|
|
79
|
+
PLIST_PATH.write_text(contents, encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _launchctl_domain_target() -> str:
|
|
83
|
+
# login_item is only imported on macOS; the guard also keeps mypy's win32
|
|
84
|
+
# run (which has no os.getuid) off the POSIX call.
|
|
85
|
+
if sys.platform == "win32":
|
|
86
|
+
raise RuntimeError("login_item is macOS-only")
|
|
87
|
+
return f"gui/{os.getuid()}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _stderr_summary(stderr: str) -> str:
|
|
91
|
+
return stderr.strip().replace("\n", " ")[:300]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _log_launchctl_error(action: str, message: str) -> None:
|
|
95
|
+
logger.warning("launchctl %s failed: %s", action, message)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _launchctl_bootstrap() -> None:
|
|
99
|
+
cmd = ["launchctl", "bootstrap", _launchctl_domain_target(), str(PLIST_PATH)]
|
|
100
|
+
try:
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
cmd,
|
|
103
|
+
capture_output=True,
|
|
104
|
+
text=True,
|
|
105
|
+
check=False,
|
|
106
|
+
timeout=5,
|
|
107
|
+
)
|
|
108
|
+
except FileNotFoundError as exc:
|
|
109
|
+
_log_launchctl_error("bootstrap", str(exc))
|
|
110
|
+
return
|
|
111
|
+
except subprocess.TimeoutExpired as exc:
|
|
112
|
+
_log_launchctl_error("bootstrap", f"timed out after {exc.timeout} seconds")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if result.returncode not in (0, 17):
|
|
116
|
+
stderr = _stderr_summary(result.stderr)
|
|
117
|
+
_log_launchctl_error("bootstrap", f"returncode={result.returncode} stderr={stderr!r}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def enable() -> None:
|
|
121
|
+
_ensure_parent_dirs()
|
|
122
|
+
_write_plist(_plist_text())
|
|
123
|
+
_launchctl_bootstrap()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _remove_plist() -> None:
|
|
127
|
+
PLIST_PATH.unlink(missing_ok=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _launchctl_bootout() -> None:
|
|
131
|
+
cmd = ["launchctl", "bootout", f"{_launchctl_domain_target()}/{LABEL}"]
|
|
132
|
+
try:
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
cmd,
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
check=False,
|
|
138
|
+
timeout=5,
|
|
139
|
+
)
|
|
140
|
+
except FileNotFoundError as exc:
|
|
141
|
+
_log_launchctl_error("bootout", str(exc))
|
|
142
|
+
return
|
|
143
|
+
except subprocess.TimeoutExpired as exc:
|
|
144
|
+
_log_launchctl_error("bootout", f"timed out after {exc.timeout} seconds")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if result.returncode not in (0, 113):
|
|
148
|
+
stderr = _stderr_summary(result.stderr)
|
|
149
|
+
_log_launchctl_error("bootout", f"returncode={result.returncode} stderr={stderr!r}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def disable() -> None:
|
|
153
|
+
_launchctl_bootout()
|
|
154
|
+
_remove_plist()
|
main.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import importlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from contextlib import suppress
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
# Inside usage.app this file sits in Contents/Resources/ next to py2app's
|
|
22
|
+
# __boot__.py, while the modules it imports live in lib/python313.zip. Running
|
|
23
|
+
# it with an outside interpreter therefore fails on the first local import with
|
|
24
|
+
# a confusing ImportError (a PyPI package named `i18n` even shadows ours). Bail
|
|
25
|
+
# out with instructions instead. py2app's own launcher sets RESOURCEPATH. (#92)
|
|
26
|
+
if not os.environ.get("RESOURCEPATH") and (Path(__file__).parent / "__boot__.py").exists():
|
|
27
|
+
sys.exit(
|
|
28
|
+
"This main.py is part of the usage.app bundle and cannot be run directly.\n"
|
|
29
|
+
'To install the status line, open usage from the menu bar and click "Set Up Status Line".\n'
|
|
30
|
+
"To run from source instead: https://github.com/aqua5230/usage"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
import prefs
|
|
34
|
+
from i18n import packaged_resource_path
|
|
35
|
+
from i18n import t as _t
|
|
36
|
+
from prefs import PREFERENCES_FILE as PREFERENCES_FILE
|
|
37
|
+
from usage_client import ClaudeUsageClient, PollOutcome, PollState
|
|
38
|
+
from usage_lang import detect_lang
|
|
39
|
+
from usage_rate import UsageRateTracker
|
|
40
|
+
|
|
41
|
+
SPRITE_INTERVAL_S = [2.0, 0.8, 0.4, 0.15] # idle/normal/active/heavy
|
|
42
|
+
IMPORT_RETRY_ATTEMPTS = 6
|
|
43
|
+
IMPORT_RETRY_DELAY_S = 3.0
|
|
44
|
+
REPAIR_DISMISS_SECONDS = 24 * 3600
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_preferences() -> dict[str, Any]:
|
|
50
|
+
return prefs._load_preferences(PREFERENCES_FILE)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _save_preferences(data: dict[str, Any]) -> None:
|
|
54
|
+
prefs._save_preferences(data, PREFERENCES_FILE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_rich() -> tuple[type[Any], type[Any]]:
|
|
58
|
+
rich_console = _import_module_with_oserror_retry("rich.console")
|
|
59
|
+
rich_live = _import_module_with_oserror_retry("rich.live")
|
|
60
|
+
return rich_console.Console, rich_live.Live
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _import_module_with_oserror_retry(name: str) -> Any:
|
|
64
|
+
"""Retry imports that can transiently fail under launchd with Errno 11."""
|
|
65
|
+
for attempt in range(IMPORT_RETRY_ATTEMPTS):
|
|
66
|
+
try:
|
|
67
|
+
return importlib.import_module(name)
|
|
68
|
+
except OSError:
|
|
69
|
+
if attempt >= IMPORT_RETRY_ATTEMPTS - 1:
|
|
70
|
+
raise
|
|
71
|
+
logger.warning("import failed for %s, retrying", name, exc_info=True)
|
|
72
|
+
time.sleep(IMPORT_RETRY_DELAY_S)
|
|
73
|
+
raise RuntimeError("unreachable")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _setup_logging() -> None:
|
|
77
|
+
import usage_logging
|
|
78
|
+
|
|
79
|
+
usage_logging.setup_logging()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _i18n_text(language: str, key: str) -> str:
|
|
83
|
+
try:
|
|
84
|
+
i18n_path = packaged_resource_path(
|
|
85
|
+
"i18n.json", Path(__file__).with_name("i18n.json")
|
|
86
|
+
)
|
|
87
|
+
data = json.loads(i18n_path.read_text(encoding="utf-8"))
|
|
88
|
+
table = data.get(language) or data.get("en") or {}
|
|
89
|
+
return str(table.get(key) or data.get("en", {}).get(key) or key)
|
|
90
|
+
except (OSError, json.JSONDecodeError, AttributeError):
|
|
91
|
+
return key
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _health_language() -> str:
|
|
95
|
+
return detect_lang()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _save_user_preference(key: str) -> None:
|
|
99
|
+
prefs = _load_preferences()
|
|
100
|
+
if key == "repair_dismissed_at":
|
|
101
|
+
prefs[key] = time.time()
|
|
102
|
+
else:
|
|
103
|
+
prefs[key] = True
|
|
104
|
+
_save_preferences(prefs)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _user_dismissed_repair_today() -> bool:
|
|
108
|
+
prefs = _load_preferences()
|
|
109
|
+
if prefs.get("no_auto_repair") is True:
|
|
110
|
+
return True
|
|
111
|
+
dismissed_at = prefs.get("repair_dismissed_at")
|
|
112
|
+
if isinstance(dismissed_at, int | float):
|
|
113
|
+
return (time.time() - float(dismissed_at)) < REPAIR_DISMISS_SECONDS
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_our_hook_in_settings() -> bool:
|
|
118
|
+
try:
|
|
119
|
+
import setup_hook
|
|
120
|
+
|
|
121
|
+
return setup_hook._detect_current_state() in {"us-direct", "us-forwarder"}
|
|
122
|
+
except Exception:
|
|
123
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
124
|
+
logger.warning("hook health check failed", exc_info=True)
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_first_run() -> bool:
|
|
129
|
+
try:
|
|
130
|
+
import setup_hook
|
|
131
|
+
import usage_client
|
|
132
|
+
|
|
133
|
+
return not setup_hook.HOOK_TARGET.exists() and not Path(usage_client.STATUS_FILE).exists()
|
|
134
|
+
except Exception:
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _show_repair_dialog() -> str:
|
|
139
|
+
language = _health_language()
|
|
140
|
+
title = _i18n_text(language, "repair_dialog_title")
|
|
141
|
+
message = _i18n_text(language, "repair_dialog_message")
|
|
142
|
+
repair = _i18n_text(language, "repair_dialog_repair")
|
|
143
|
+
skip = _i18n_text(language, "repair_dialog_skip")
|
|
144
|
+
never = _i18n_text(language, "repair_dialog_never")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
appkit = importlib.import_module("AppKit")
|
|
148
|
+
alert = appkit.NSAlert.alloc().init()
|
|
149
|
+
alert.setMessageText_(title)
|
|
150
|
+
alert.setInformativeText_(message)
|
|
151
|
+
alert.addButtonWithTitle_(repair)
|
|
152
|
+
alert.addButtonWithTitle_(skip)
|
|
153
|
+
alert.addButtonWithTitle_(never)
|
|
154
|
+
result = int(alert.runModal())
|
|
155
|
+
except Exception:
|
|
156
|
+
print(f"{title}: {message}")
|
|
157
|
+
return "skip"
|
|
158
|
+
|
|
159
|
+
if result == 1000:
|
|
160
|
+
return "repair"
|
|
161
|
+
if result == 1002:
|
|
162
|
+
return "never"
|
|
163
|
+
return "skip"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def health_check() -> None:
|
|
167
|
+
if _is_our_hook_in_settings():
|
|
168
|
+
return
|
|
169
|
+
if _is_first_run():
|
|
170
|
+
return
|
|
171
|
+
if _user_dismissed_repair_today():
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
choice = _show_repair_dialog()
|
|
175
|
+
if choice == "repair":
|
|
176
|
+
import session_hooks
|
|
177
|
+
import setup_hook
|
|
178
|
+
|
|
179
|
+
setup_hook.setup(force_forwarder=True)
|
|
180
|
+
session_hooks._migrate_bundled_python_commands_if_needed()
|
|
181
|
+
elif choice == "never":
|
|
182
|
+
_save_user_preference("no_auto_repair")
|
|
183
|
+
else:
|
|
184
|
+
_save_user_preference("repair_dismissed_at")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _self_heal() -> None:
|
|
188
|
+
try:
|
|
189
|
+
import session_hooks
|
|
190
|
+
|
|
191
|
+
session_hooks.self_heal()
|
|
192
|
+
except Exception:
|
|
193
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
194
|
+
logger.warning("self-heal failed", exc_info=True)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
from usage_dir_sweeper import sweep_stale_temp_files
|
|
198
|
+
|
|
199
|
+
sweep_stale_temp_files()
|
|
200
|
+
except Exception:
|
|
201
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
202
|
+
logger.warning("stale temp file sweep failed", exc_info=True)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_args() -> argparse.Namespace:
|
|
206
|
+
parser = argparse.ArgumentParser(description="顯示 Claude Code 用量的工具")
|
|
207
|
+
parser.add_argument("--mock", action="store_true", help="使用假資料預覽介面")
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--interval",
|
|
210
|
+
type=int,
|
|
211
|
+
default=60,
|
|
212
|
+
help="輪詢秒數,預設 60,最小 30",
|
|
213
|
+
)
|
|
214
|
+
parser.add_argument(
|
|
215
|
+
"--tui",
|
|
216
|
+
action="store_true",
|
|
217
|
+
help="使用舊版終端機 TUI 介面",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"--force-group",
|
|
221
|
+
type=int,
|
|
222
|
+
choices=[0, 1, 2, 3],
|
|
223
|
+
default=None,
|
|
224
|
+
help="強制使用某速率組(測試用,僅 TUI 模式有效),0=Idle 1=Normal 2=Active 3=Heavy",
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"--setup",
|
|
228
|
+
action="store_true",
|
|
229
|
+
help="安裝 statusLine hook 到 Claude Code(首次使用必跑)",
|
|
230
|
+
)
|
|
231
|
+
parser.add_argument(
|
|
232
|
+
"--unsetup",
|
|
233
|
+
action="store_true",
|
|
234
|
+
help="從 Claude Code 移除 statusLine hook 並還原原設定",
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
"--doctor",
|
|
238
|
+
action="store_true",
|
|
239
|
+
help=argparse.SUPPRESS,
|
|
240
|
+
)
|
|
241
|
+
parser.add_argument(
|
|
242
|
+
"--json",
|
|
243
|
+
action="store_true",
|
|
244
|
+
help=argparse.SUPPRESS,
|
|
245
|
+
)
|
|
246
|
+
args = parser.parse_args()
|
|
247
|
+
args.interval = max(30, args.interval)
|
|
248
|
+
return args
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def poll_usage(
|
|
252
|
+
client: ClaudeUsageClient,
|
|
253
|
+
state: Any,
|
|
254
|
+
stop_event: asyncio.Event,
|
|
255
|
+
) -> None:
|
|
256
|
+
while not stop_event.is_set():
|
|
257
|
+
try:
|
|
258
|
+
await asyncio.wait_for(stop_event.wait(), timeout=client.interval_seconds)
|
|
259
|
+
return
|
|
260
|
+
except TimeoutError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
state.poll_state = PollState.LOADING
|
|
264
|
+
outcome = await client.fetch_once()
|
|
265
|
+
_apply_outcome(state, outcome)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _apply_outcome(state: Any, outcome: PollOutcome) -> None:
|
|
269
|
+
state.poll_state = outcome.state
|
|
270
|
+
if outcome.snapshot is not None:
|
|
271
|
+
state.snapshot = outcome.snapshot
|
|
272
|
+
if outcome.message:
|
|
273
|
+
state.message = outcome.message
|
|
274
|
+
if outcome.state == PollState.SUCCESS:
|
|
275
|
+
state.fatal_message = None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def run_tui(mock: bool, interval: int, force_group: int | None = None) -> None:
|
|
279
|
+
tui = _import_module_with_oserror_retry("tui")
|
|
280
|
+
Console, Live = _load_rich()
|
|
281
|
+
console = Console()
|
|
282
|
+
state = tui.AppViewState()
|
|
283
|
+
tracker = UsageRateTracker(forced_group=force_group, mock=mock)
|
|
284
|
+
stop_event = asyncio.Event()
|
|
285
|
+
client = ClaudeUsageClient(interval_seconds=interval, mock=mock)
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
first_outcome = await client.fetch_once()
|
|
289
|
+
_apply_outcome(state, first_outcome)
|
|
290
|
+
|
|
291
|
+
poll_task = asyncio.create_task(poll_usage(client, state, stop_event))
|
|
292
|
+
|
|
293
|
+
with Live(
|
|
294
|
+
tui.render_screen(state, 0),
|
|
295
|
+
console=console,
|
|
296
|
+
screen=True,
|
|
297
|
+
refresh_per_second=10,
|
|
298
|
+
transient=False,
|
|
299
|
+
) as live:
|
|
300
|
+
start_time = time.monotonic()
|
|
301
|
+
while not stop_event.is_set():
|
|
302
|
+
now = time.monotonic()
|
|
303
|
+
|
|
304
|
+
effective_group = tracker.group()
|
|
305
|
+
state.rate_group = effective_group
|
|
306
|
+
|
|
307
|
+
interval_s = SPRITE_INTERVAL_S[effective_group]
|
|
308
|
+
frame_index = int((now - start_time) / interval_s) % 4
|
|
309
|
+
|
|
310
|
+
live.update(tui.render_screen(state, frame_index), refresh=True)
|
|
311
|
+
await asyncio.sleep(0.1)
|
|
312
|
+
|
|
313
|
+
await poll_task
|
|
314
|
+
finally:
|
|
315
|
+
stop_event.set()
|
|
316
|
+
await client.aclose()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main() -> None:
|
|
320
|
+
_setup_logging()
|
|
321
|
+
args = parse_args()
|
|
322
|
+
if args.doctor:
|
|
323
|
+
import doctor
|
|
324
|
+
|
|
325
|
+
report = doctor.collect()
|
|
326
|
+
output = doctor.render_json(report) if args.json else doctor.render(report)
|
|
327
|
+
print(output, end="")
|
|
328
|
+
raise SystemExit(doctor.exit_code(report))
|
|
329
|
+
if args.setup:
|
|
330
|
+
import session_hooks
|
|
331
|
+
from setup_hook import setup
|
|
332
|
+
|
|
333
|
+
exit_code = setup()
|
|
334
|
+
if exit_code == 0:
|
|
335
|
+
session_hooks._migrate_bundled_python_commands_if_needed()
|
|
336
|
+
raise SystemExit(exit_code)
|
|
337
|
+
if args.unsetup:
|
|
338
|
+
from session_hooks import disable_session_resume, disable_terse_mode
|
|
339
|
+
from setup_hook import unsetup
|
|
340
|
+
|
|
341
|
+
disable_session_resume()
|
|
342
|
+
disable_terse_mode()
|
|
343
|
+
raise SystemExit(unsetup())
|
|
344
|
+
_self_heal()
|
|
345
|
+
if args.tui:
|
|
346
|
+
with suppress(KeyboardInterrupt):
|
|
347
|
+
asyncio.run(
|
|
348
|
+
run_tui(mock=args.mock, interval=args.interval, force_group=args.force_group)
|
|
349
|
+
)
|
|
350
|
+
elif sys.platform == "darwin":
|
|
351
|
+
menubar = _import_module_with_oserror_retry("menubar")
|
|
352
|
+
menubar.show_forwarder_mode_prompt_if_needed()
|
|
353
|
+
menubar.run_app(mock=args.mock, interval=args.interval)
|
|
354
|
+
elif sys.platform == "win32":
|
|
355
|
+
try:
|
|
356
|
+
wintray = importlib.import_module("wintray")
|
|
357
|
+
except ModuleNotFoundError as exc:
|
|
358
|
+
# Any missing module — wintray itself, an optional GUI package, or a
|
|
359
|
+
# transitive import — must degrade to the TUI; a bare raise would exit
|
|
360
|
+
# a --windowed build with no visible output at all.
|
|
361
|
+
print(f"{_t('wintray_unavailable')} [{exc.name}]")
|
|
362
|
+
with suppress(KeyboardInterrupt):
|
|
363
|
+
asyncio.run(
|
|
364
|
+
run_tui(mock=args.mock, interval=args.interval, force_group=args.force_group)
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
try:
|
|
368
|
+
wintray.run_app(mock=args.mock, interval=args.interval)
|
|
369
|
+
except ModuleNotFoundError as exc:
|
|
370
|
+
print(f"{_t('wintray_unavailable')} [{exc.name}]")
|
|
371
|
+
with suppress(KeyboardInterrupt):
|
|
372
|
+
asyncio.run(
|
|
373
|
+
run_tui(
|
|
374
|
+
mock=args.mock,
|
|
375
|
+
interval=args.interval,
|
|
376
|
+
force_group=args.force_group,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
with suppress(KeyboardInterrupt):
|
|
381
|
+
asyncio.run(
|
|
382
|
+
run_tui(mock=args.mock, interval=args.interval, force_group=args.force_group)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
main()
|