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
usage_dir_sweeper.py ADDED
@@ -0,0 +1,100 @@
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
+ """Sweep orphaned ``mkstemp`` remnants out of ``~/.usage/``.
8
+
9
+ Every atomic write in this project has the same shape: ``mkstemp`` beside the
10
+ target, write, ``os.replace``, and a ``finally`` that unlinks the temp file if
11
+ anything went wrong. That cleanup cannot run when the process is SIGKILLed or
12
+ crashes mid-write, so the ``.tmp`` outlives it — one real install accumulated
13
+ 40 MB of them over twelve days, mostly from the large JSONL caches whose long
14
+ writes are the likeliest to be interrupted.
15
+
16
+ Those write paths are correct and stay untouched. This module only collects
17
+ what a killed process left behind, once, at startup.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import stat
25
+ import time
26
+ from contextlib import suppress
27
+ from pathlib import Path
28
+
29
+ USAGE_DIR = Path(os.path.expanduser("~/.usage"))
30
+ # Another usage process (for example, the menu bar app and TUI together) may
31
+ # still be writing a large cache. Twenty-four hours is far longer than any
32
+ # single write, so one process cannot remove another process's active temp file.
33
+ STALE_TEMP_AGE_SECONDS = 24 * 3600
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ def _debug_warning(message: str, *args: object) -> None:
39
+ with suppress(Exception):
40
+ if os.environ.get("USAGE_DEBUG") == "1":
41
+ logger.warning(message, *args, exc_info=True)
42
+
43
+
44
+ def _is_real_directory(path: Path) -> bool:
45
+ try:
46
+ return stat.S_ISDIR(path.lstat().st_mode)
47
+ except OSError:
48
+ return False
49
+
50
+
51
+ def _sweep_directory(directory: Path, now: float) -> int:
52
+ deleted = 0
53
+ try:
54
+ entries = directory.iterdir()
55
+ for path in entries:
56
+ if not (path.name.startswith("tmp") and path.name.endswith(".tmp")):
57
+ continue
58
+ try:
59
+ file_stat = path.lstat()
60
+ if not stat.S_ISREG(file_stat.st_mode):
61
+ continue
62
+ if now - file_stat.st_mtime < STALE_TEMP_AGE_SECONDS:
63
+ continue
64
+ path.unlink()
65
+ deleted += 1
66
+ except OSError:
67
+ _debug_warning("failed to inspect or remove stale temp file %s", path)
68
+ except OSError:
69
+ _debug_warning("failed to scan usage directory %s", directory)
70
+ return deleted
71
+
72
+
73
+ def sweep_stale_temp_files(root: Path | None = None, *, now: float | None = None) -> int:
74
+ """Remove stale mkstemp remnants from the usage directory.
75
+
76
+ Scans ``root`` plus its direct ``*.d`` children (where the sharded caches
77
+ put their temp files) and no deeper. Symlinks and directories are never
78
+ touched no matter how well their name matches. Returns the number deleted;
79
+ never raises.
80
+ """
81
+ deleted = 0
82
+ try:
83
+ scan_root = USAGE_DIR if root is None else root
84
+ current_time = time.time() if now is None else now
85
+ if not _is_real_directory(scan_root):
86
+ return 0
87
+
88
+ directories = [scan_root]
89
+ try:
90
+ for child in scan_root.iterdir():
91
+ if child.name.endswith(".d") and _is_real_directory(child):
92
+ directories.append(child)
93
+ except OSError:
94
+ _debug_warning("failed to inspect usage subdirectories in %s", scan_root)
95
+
96
+ for directory in directories:
97
+ deleted += _sweep_directory(directory, current_time)
98
+ except Exception:
99
+ _debug_warning("unexpected failure while sweeping stale usage temp files")
100
+ return deleted
usage_lang.py ADDED
@@ -0,0 +1,79 @@
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 os
10
+ import sys
11
+ from collections.abc import Mapping
12
+
13
+
14
+ def _normalize_lang(code: str | None) -> str:
15
+ if not code:
16
+ return "en"
17
+ normalized = code.split(".")[0].strip().lower().replace("_", "-")
18
+
19
+ if normalized in {"zh-tw", "zh-hk", "zh-hant"} or normalized.startswith(
20
+ ("zh-tw-", "zh-hant")
21
+ ):
22
+ return "zh-TW"
23
+ if normalized in {"zh-cn", "zh-sg", "zh-hans", "zh"} or normalized.startswith(
24
+ ("zh-cn-", "zh-hans")
25
+ ):
26
+ return "zh-CN"
27
+ if normalized.startswith("en"):
28
+ return "en"
29
+ if normalized.startswith("ja"):
30
+ return "ja"
31
+ if normalized.startswith("ko"):
32
+ return "ko"
33
+ return "en"
34
+
35
+
36
+ def _detect_macos_lang() -> str:
37
+ try:
38
+ from Foundation import NSLocale
39
+
40
+ preferred = NSLocale.preferredLanguages()
41
+ if preferred:
42
+ return _normalize_lang(str(preferred[0]))
43
+ locale = NSLocale.currentLocale()
44
+ identifier_attr = getattr(locale, "localeIdentifier", None)
45
+ identifier = identifier_attr() if callable(identifier_attr) else identifier_attr
46
+ return _normalize_lang(str(identifier) if identifier is not None else None)
47
+ except Exception:
48
+ return "en"
49
+
50
+
51
+ def _detect_windows_lang() -> str:
52
+ try:
53
+ import ctypes
54
+ import locale
55
+
56
+ windll = getattr(ctypes, "windll", None)
57
+ if windll is None:
58
+ return "en"
59
+ lang_id = int(windll.kernel32.GetUserDefaultUILanguage())
60
+ return _normalize_lang(locale.windows_locale.get(lang_id))
61
+ except Exception:
62
+ return "en"
63
+
64
+
65
+ def detect_lang(env: Mapping[str, str] | None = None) -> str:
66
+ source = os.environ if env is None else env
67
+ # Windows 上的 LANG 多半是 Git Bash / MSYS 帶進來的,不代表使用者的系統語言。
68
+ keys = (
69
+ ("USAGE_LANG", "TT_LANG") if sys.platform == "win32" else ("USAGE_LANG", "TT_LANG", "LANG")
70
+ )
71
+ for key in keys:
72
+ value = source.get(key, "").strip()
73
+ if value:
74
+ return _normalize_lang(value)
75
+ if env is None:
76
+ if sys.platform == "win32":
77
+ return _detect_windows_lang()
78
+ return _detect_macos_lang()
79
+ return "en"
usage_logging.py ADDED
@@ -0,0 +1,75 @@
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
+ """Application logging with bounded rotating file output."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import logging
13
+ import os
14
+ import sys
15
+ from logging.handlers import RotatingFileHandler
16
+ from pathlib import Path
17
+
18
+ # macOS keeps app logs under ~/Library/Logs; every other platform reuses the
19
+ # ~/.usage directory that already holds the disk caches and the quarantine.
20
+ LOG_DIR = (
21
+ Path.home() / "Library" / "Logs" / "usage"
22
+ if sys.platform == "darwin"
23
+ else Path.home() / ".usage" / "logs"
24
+ )
25
+ LOG_FILENAME = "usage-app.log"
26
+ MAX_BYTES = 2 * 1024 * 1024
27
+ BACKUP_COUNT = 3
28
+ LOG_FORMAT = "%(asctime)s %(name)s %(levelname)s: %(message)s"
29
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
30
+ _GLOBAL_DEBUG_FLAGS = {"1", "all", "*"}
31
+
32
+
33
+ class _PrivateRotatingFileHandler(RotatingFileHandler):
34
+ """Keep the active log private when creation and rotation open a new file."""
35
+
36
+ def _open(self): # type: ignore[no-untyped-def]
37
+ stream = super()._open()
38
+ with contextlib.suppress(OSError):
39
+ os.chmod(self.baseFilename, 0o600)
40
+ return stream
41
+
42
+
43
+ def parse_debug_flags(raw: str | None) -> tuple[bool, list[str]]:
44
+ """Return whether debug is global and the requested logger names."""
45
+ flags = [flag.strip().lower() for flag in raw.split(",") if flag.strip()] if raw else []
46
+ if any(flag in _GLOBAL_DEBUG_FLAGS for flag in flags):
47
+ return True, []
48
+ return False, flags
49
+
50
+
51
+ def setup_logging() -> None:
52
+ """Configure console output and best-effort rotating file logging."""
53
+ global_debug, debug_loggers = parse_debug_flags(os.environ.get("USAGE_DEBUG"))
54
+ level = logging.DEBUG if global_debug else logging.WARNING
55
+ logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=DATE_FORMAT)
56
+
57
+ root_logger = logging.getLogger()
58
+ root_logger.setLevel(level)
59
+ for name in debug_loggers:
60
+ logging.getLogger(name).setLevel(logging.DEBUG)
61
+
62
+ try:
63
+ LOG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
64
+ file_handler = _PrivateRotatingFileHandler(
65
+ LOG_DIR / LOG_FILENAME,
66
+ maxBytes=MAX_BYTES,
67
+ backupCount=BACKUP_COUNT,
68
+ encoding="utf-8",
69
+ )
70
+ except OSError:
71
+ return
72
+
73
+ file_handler.setLevel(logging.DEBUG)
74
+ file_handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
75
+ root_logger.addHandler(file_handler)
usage_notifications.py ADDED
@@ -0,0 +1,96 @@
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
+ from dataclasses import dataclass, field
10
+ from typing import Literal
11
+
12
+ from burn_rate import RESET_DROP_PERCENT
13
+
14
+ NotificationKind = Literal["warn", "depleted", "restored"]
15
+
16
+ VALID_CHANNELS = frozenset(
17
+ {
18
+ "claude_session",
19
+ "claude_weekly",
20
+ "codex_session",
21
+ "codex_weekly",
22
+ }
23
+ )
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class NotificationEvent:
28
+ kind: NotificationKind
29
+ channel: str
30
+ threshold: float | None
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class _ChannelState:
35
+ last_percent: float | None = None
36
+ warned_thresholds: set[float] = field(default_factory=set)
37
+ depleted: bool = False
38
+
39
+
40
+ class QuotaNotifier:
41
+ def __init__(self, thresholds: list[float] | None = None) -> None:
42
+ values = [90.0] if thresholds is None else thresholds
43
+ self.thresholds = sorted({float(value) for value in values})
44
+ self._channels = {channel: _ChannelState() for channel in VALID_CHANNELS}
45
+
46
+ def update(
47
+ self,
48
+ channels: dict[str, tuple[float | None, bool]],
49
+ ) -> list[NotificationEvent]:
50
+ events: list[NotificationEvent] = []
51
+ for channel, (percent, available) in channels.items():
52
+ if channel not in VALID_CHANNELS:
53
+ continue
54
+ events.extend(self._update_channel(channel, percent, available))
55
+ return events
56
+
57
+ def _update_channel(
58
+ self,
59
+ channel: str,
60
+ percent: float | None,
61
+ available: bool,
62
+ ) -> list[NotificationEvent]:
63
+ state = self._channels[channel]
64
+ events: list[NotificationEvent] = []
65
+ current = float(percent) if percent is not None else None
66
+
67
+ reset = (
68
+ current is not None
69
+ and state.last_percent is not None
70
+ and (state.last_percent - current) > RESET_DROP_PERCENT
71
+ )
72
+ if reset:
73
+ was_depleted = state.depleted
74
+ state.warned_thresholds.clear()
75
+ state.depleted = False
76
+ if was_depleted and available and current is not None and current < 100.0:
77
+ events.append(NotificationEvent("restored", channel, None))
78
+
79
+ depleted = current is not None and current >= 100.0
80
+ if depleted:
81
+ if not state.depleted:
82
+ events.append(NotificationEvent("depleted", channel, None))
83
+ state.depleted = True
84
+ elif current is not None:
85
+ previous = state.last_percent
86
+ if previous is not None:
87
+ for threshold in self.thresholds:
88
+ crossed = previous < threshold <= current
89
+ if crossed and threshold not in state.warned_thresholds:
90
+ events.append(NotificationEvent("warn", channel, threshold))
91
+ state.warned_thresholds.add(threshold)
92
+ state.depleted = False
93
+
94
+ if current is not None:
95
+ state.last_percent = current
96
+ return events
usage_rate.py ADDED
@@ -0,0 +1,97 @@
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 os
10
+ import time
11
+ from collections.abc import Callable
12
+ from datetime import datetime, timezone
13
+
14
+ from history_loader import UsageEntry, load_entries
15
+
16
+ BURN_RATE_THRESH_NORMAL = 500.0 # tokens/min
17
+ BURN_RATE_THRESH_ACTIVE = 2500.0
18
+ BURN_RATE_THRESH_HEAVY = 6000.0
19
+
20
+ GROUP_NAMES = ["Idle", "Normal", "Active", "Heavy"]
21
+
22
+
23
+ def _utc_now() -> datetime:
24
+ return datetime.now(timezone.utc) # noqa: UP017 - keep timezone.utc explicit.
25
+
26
+
27
+ class UsageRateTracker:
28
+ def __init__(
29
+ self,
30
+ forced_group: int | None = None,
31
+ mock: bool = False,
32
+ load: Callable[[int], list[UsageEntry]] | None = None,
33
+ ) -> None:
34
+ self.forced_group = forced_group
35
+ self.mock = mock
36
+ self._load = load
37
+ self._cached_group: int | None = None
38
+ self._cache_expires_at = 0.0
39
+
40
+ def group(self) -> int:
41
+ forced_group = self._forced_group()
42
+ if forced_group is not None:
43
+ return forced_group
44
+ if self.mock:
45
+ return 0
46
+
47
+ now = time.monotonic()
48
+ if self._cached_group is not None and now < self._cache_expires_at:
49
+ return self._cached_group
50
+
51
+ entries = (
52
+ self._load(1)
53
+ if self._load is not None
54
+ else load_entries(hours_back=1)
55
+ )
56
+ if not entries:
57
+ result = 0
58
+ self._cached_group = result
59
+ self._cache_expires_at = time.monotonic() + 30
60
+ return result
61
+
62
+ active_tokens = sum(entry.active_tokens for entry in entries)
63
+ elapsed_seconds = (_utc_now() - entries[0].timestamp).total_seconds()
64
+ # Match burn_rate's 5-minute floor: a single cache-creation burst should
65
+ # not trigger Heavy on its own.
66
+ elapsed_minutes = max(elapsed_seconds / 60.0, 5.0)
67
+ burn_rate = active_tokens / min(elapsed_minutes, 60.0)
68
+
69
+ if burn_rate < BURN_RATE_THRESH_NORMAL:
70
+ result = 0
71
+ elif burn_rate < BURN_RATE_THRESH_ACTIVE:
72
+ result = 1
73
+ elif burn_rate < BURN_RATE_THRESH_HEAVY:
74
+ result = 2
75
+ else:
76
+ result = 3
77
+
78
+ self._cached_group = result
79
+ self._cache_expires_at = time.monotonic() + 30
80
+ return result
81
+
82
+ def _forced_group(self) -> int | None:
83
+ if self.forced_group is not None:
84
+ return self.forced_group
85
+
86
+ raw_value = os.environ.get("USAGE_FORCE_GROUP")
87
+ if raw_value is None:
88
+ return None
89
+
90
+ try:
91
+ group = int(raw_value)
92
+ except ValueError:
93
+ return None
94
+
95
+ if 0 <= group < len(GROUP_NAMES):
96
+ return group
97
+ return None