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
statusline_settings.py ADDED
@@ -0,0 +1,160 @@
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 contextlib
10
+ import json
11
+ import os
12
+ import shlex
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from setup_hook import _atomic_write_text
18
+
19
+
20
+ def _claude_settings_path() -> Path:
21
+ return Path(os.path.expanduser("~/.claude/settings.json"))
22
+
23
+
24
+ def _load_claude_settings() -> dict[str, Any]:
25
+ settings_path = _claude_settings_path()
26
+ if not settings_path.exists():
27
+ return {}
28
+ with settings_path.open(encoding="utf-8") as file:
29
+ settings = json.load(file)
30
+ if not isinstance(settings, dict):
31
+ raise ValueError(f"{settings_path} must be a JSON object")
32
+ return settings
33
+
34
+
35
+ def _save_claude_settings(settings: dict[str, Any]) -> None:
36
+ settings_path = _claude_settings_path()
37
+ trailing_newline = True
38
+ with contextlib.suppress(OSError):
39
+ trailing_newline = settings_path.read_bytes().endswith(b"\n")
40
+ content = json.dumps(settings, indent=2, ensure_ascii=False)
41
+ if trailing_newline:
42
+ content += "\n"
43
+ _atomic_write_text(settings_path, content)
44
+
45
+
46
+ def _statusline_command_target_exists(statusline: object) -> bool:
47
+ if not isinstance(statusline, dict):
48
+ return True
49
+ command = statusline.get("command")
50
+ if not isinstance(command, str):
51
+ return True
52
+ try:
53
+ parts = shlex.split(command)
54
+ except ValueError:
55
+ return True
56
+ for part in parts:
57
+ if "statusline" not in part or not part.endswith(".py"):
58
+ continue
59
+ return Path(os.path.expanduser(part)).exists()
60
+ return True
61
+
62
+
63
+ def _set_forwarder_mode_prompt_dismissed() -> None:
64
+ import setup_hook
65
+
66
+ settings = setup_hook._load_settings()
67
+ usage_settings = settings.get(setup_hook.BACKUP_KEY)
68
+ if not isinstance(usage_settings, dict):
69
+ usage_settings = {}
70
+ settings[setup_hook.BACKUP_KEY] = usage_settings
71
+ usage_settings["forwarderModePromptDismissed"] = True
72
+ setup_hook._save_settings(settings)
73
+
74
+
75
+ def _sync_agy_statusline(enable: bool) -> None:
76
+ """Mirror the switch onto Antigravity's status line when its CLI is installed.
77
+
78
+ Antigravity is a side effect of the one switch the panel shows, so a missing
79
+ or unwritable ~/.gemini config must never stop Claude's own status line from
80
+ being toggled.
81
+ """
82
+ if sys.platform not in {"darwin", "win32"}:
83
+ return
84
+ import setup_hook
85
+
86
+ with contextlib.suppress(Exception):
87
+ if enable:
88
+ setup_hook._setup_agy()
89
+ else:
90
+ setup_hook._unsetup_agy()
91
+
92
+
93
+ def _disable_statusline_settings() -> int:
94
+ _sync_agy_statusline(False)
95
+ settings = _load_claude_settings()
96
+ if "statusLine" not in settings:
97
+ return 0
98
+ usage_settings = settings.setdefault("usage", {})
99
+ if not isinstance(usage_settings, dict):
100
+ usage_settings = {}
101
+ settings["usage"] = usage_settings
102
+ usage_settings["previousStatusLine"] = settings["statusLine"]
103
+ del settings["statusLine"]
104
+ _save_claude_settings(settings)
105
+ return 0
106
+
107
+
108
+ def _enable_statusline_settings() -> int:
109
+ _sync_agy_statusline(True)
110
+ settings = _load_claude_settings()
111
+ if "statusLine" in settings:
112
+ return 0
113
+ raw_usage_settings = settings.get("usage")
114
+ usage_settings = raw_usage_settings if isinstance(raw_usage_settings, dict) else None
115
+ previous = usage_settings.get("previousStatusLine") if usage_settings is not None else None
116
+ if previous:
117
+ assert usage_settings is not None
118
+ if not _statusline_command_target_exists(previous):
119
+ del usage_settings["previousStatusLine"]
120
+ if not usage_settings:
121
+ del settings["usage"]
122
+ _save_claude_settings(settings)
123
+ import setup_hook
124
+
125
+ return setup_hook.setup()
126
+ settings["statusLine"] = previous
127
+ del usage_settings["previousStatusLine"]
128
+ if not usage_settings:
129
+ del settings["usage"]
130
+ _save_claude_settings(settings)
131
+ return 0
132
+
133
+ import setup_hook
134
+
135
+ exit_code = setup_hook.setup()
136
+ if exit_code != 0 and setup_hook.is_agy_setup():
137
+ return 0
138
+ return exit_code
139
+
140
+
141
+ def _toggle_statusline_settings() -> tuple[str, int]:
142
+ if _statusline_enabled():
143
+ return "uninstall", _disable_statusline_settings()
144
+ return "install", _enable_statusline_settings()
145
+
146
+
147
+ def _statusline_enabled() -> bool:
148
+ try:
149
+ settings = _load_claude_settings()
150
+ except (OSError, ValueError, json.JSONDecodeError):
151
+ return False
152
+ if "statusLine" in settings:
153
+ return True
154
+ if sys.platform not in {"darwin", "win32"}:
155
+ return False
156
+ with contextlib.suppress(Exception):
157
+ import setup_hook
158
+
159
+ return setup_hook.is_agy_setup()
160
+ return False
@@ -0,0 +1,243 @@
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
6
+ # disclaimer.
7
+
8
+ # mypy: disable-error-code="import-untyped,import-not-found,misc"
9
+ """Bridge to the external ``instate-cli`` executable that powers the
10
+ "AI 人才市場" panel.
11
+
12
+ The CLI is built in a separate project (``~/Developer/instate``) and bundled
13
+ into ``vendor/instate-cli`` at .app build time. It prints one JSON object per
14
+ invocation on stdout. Every wrapper here returns a dict and never raises, so a
15
+ missing/broken CLI degrades to an empty-state panel instead of crashing the
16
+ popover. Folder selection uses a native ``NSOpenPanel`` (no CLI round-trip).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import subprocess
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ TIMEOUT_SECONDS = 10.0
30
+
31
+
32
+ def _repo_root() -> Path:
33
+ return Path(__file__).resolve().parent
34
+
35
+
36
+ def _bundled_cli_path() -> Path | None:
37
+ try:
38
+ from Foundation import NSBundle
39
+ except ImportError:
40
+ return None
41
+ bundle = NSBundle.mainBundle()
42
+ if bundle is None:
43
+ return None
44
+ # py2app flattens a single-file resource to Resources/<basename>, so the
45
+ # compiled CLI lives at Resources/instate-cli. In dev (python3 main.py) the
46
+ # main bundle is the Python framework and this returns None → fall back to
47
+ # repo vendor/instate-cli.
48
+ path = bundle.pathForResource_ofType_("instate-cli", "")
49
+ if path:
50
+ return Path(str(path))
51
+ return None
52
+
53
+
54
+ def _cli_path() -> Path:
55
+ bundled = _bundled_cli_path()
56
+ if bundled is not None:
57
+ return bundled
58
+ vendor = "vendor"
59
+ name = "instate-cli"
60
+ return _repo_root() / vendor / name
61
+
62
+
63
+ def _run(args: list[str]) -> dict[str, Any]:
64
+ cli = _cli_path()
65
+ if not cli.exists():
66
+ return {"ok": False, "status": "missing", "error": "instate-cli not installed"}
67
+ try:
68
+ proc = subprocess.run( # noqa: S603 — fixed local binary, args are constants/IDs
69
+ [str(cli), *args],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=TIMEOUT_SECONDS,
73
+ encoding="utf-8",
74
+ errors="replace",
75
+ )
76
+ except subprocess.TimeoutExpired:
77
+ return {"ok": False, "status": "timeout", "error": "instate-cli timed out"}
78
+ except Exception as exc:
79
+ # Any failure to spawn/read (OSError, decode hiccups, ...) degrades to
80
+ # an error dict — the panel shows its empty state instead of crashing.
81
+ return {"ok": False, "status": "error", "error": str(exc)}
82
+ if proc.returncode != 0:
83
+ return {
84
+ "ok": False,
85
+ "status": "error",
86
+ "error": (proc.stderr or "").strip() or f"instate-cli exited {proc.returncode}",
87
+ }
88
+ try:
89
+ parsed = json.loads(proc.stdout)
90
+ except json.JSONDecodeError:
91
+ return {
92
+ "ok": False,
93
+ "status": "error",
94
+ "error": "instate-cli returned non-JSON",
95
+ "stdout": proc.stdout,
96
+ }
97
+ if not isinstance(parsed, dict):
98
+ return {"ok": False, "status": "error", "error": "instate-cli returned non-object"}
99
+ return parsed
100
+
101
+
102
+ def list_state(lang: str | None = None) -> dict[str, Any]:
103
+ args = ["list-state"]
104
+ if lang:
105
+ args.append(lang)
106
+ return _run(args)
107
+
108
+
109
+ def list_personas(lang: str | None = None) -> list[dict[str, str]]:
110
+ """Return every available role as a flat, discussion-friendly record."""
111
+ try:
112
+ state = list_state(lang)
113
+ except Exception:
114
+ return []
115
+ packs = state.get("packs")
116
+ if not isinstance(packs, list):
117
+ return []
118
+ personas: list[dict[str, str]] = []
119
+ for pack in packs:
120
+ if not isinstance(pack, dict):
121
+ continue
122
+ pack_id = pack.get("id")
123
+ pack_name = pack.get("name")
124
+ if not isinstance(pack_id, str) or not isinstance(pack_name, str):
125
+ pack_id = ""
126
+ pack_name = ""
127
+ roles = pack.get("roles")
128
+ if not isinstance(roles, list):
129
+ continue
130
+ for role in roles:
131
+ if not isinstance(role, dict):
132
+ continue
133
+ role_id = role.get("id")
134
+ name = role.get("name")
135
+ persona_name = role.get("personaName")
136
+ description = role.get("description")
137
+ system_prompt = role.get("systemPrompt")
138
+ if (
139
+ not isinstance(role_id, str)
140
+ or not isinstance(name, str)
141
+ or not isinstance(persona_name, str)
142
+ or not isinstance(description, str)
143
+ or not isinstance(system_prompt, str)
144
+ ):
145
+ continue
146
+ personas.append(
147
+ {
148
+ "id": role_id,
149
+ "name": name,
150
+ "persona_name": persona_name,
151
+ "description": description,
152
+ "system_prompt": system_prompt,
153
+ "pack_id": pack_id,
154
+ "pack_name": pack_name,
155
+ }
156
+ )
157
+ return personas
158
+
159
+
160
+ def install_role(role_id: str) -> dict[str, Any]:
161
+ return _run(["install", role_id])
162
+
163
+
164
+ def launch_role(role_id: str, task_prompt: str | None = None) -> dict[str, Any]:
165
+ args = ["launch", role_id]
166
+ if task_prompt:
167
+ args.append(task_prompt)
168
+ return _run(args)
169
+
170
+
171
+ def restore_role(role_id: str) -> dict[str, Any]:
172
+ return _run(["restore", role_id])
173
+
174
+
175
+ def ignore_drift(role_id: str) -> dict[str, Any]:
176
+ return _run(["ignore-drift", role_id])
177
+
178
+
179
+ def set_folder(role_id: str, path: str) -> dict[str, Any]:
180
+ return _run(["set-folder", role_id, path])
181
+
182
+
183
+ def pick_folder() -> str | None:
184
+ """Open a native folder picker; return the chosen path or ``None``.
185
+
186
+ The popover's content window sits at ``NSNormalWindowLevel`` and usage runs
187
+ as an accessory app (no Dock icon). A bare ``runModal`` opens the panel at
188
+ the same level, so it lands behind the still-open popover and never takes
189
+ focus — the user clicks "選擇資料夾" and nothing visible happens. Two nudges
190
+ fix it: activate the app so the panel can become key, and raise the panel
191
+ one notch above ``NSPopUpMenuWindowLevel`` so it clears the popover.
192
+ """
193
+ try:
194
+ from AppKit import NSApp, NSOpenPanel, NSPopUpMenuWindowLevel
195
+ except ImportError:
196
+ return None
197
+ NSApp.activateIgnoringOtherApps_(True)
198
+ panel = NSOpenPanel.openPanel()
199
+ panel.setCanChooseFiles_(False)
200
+ panel.setCanChooseDirectories_(True)
201
+ panel.setAllowsMultipleSelection_(False)
202
+ panel.setResolvesAliases_(True)
203
+ panel.setLevel_(NSPopUpMenuWindowLevel + 1)
204
+ if panel.runModal() != 1: # NSModalResponseOK
205
+ return None
206
+ urls = panel.URLs()
207
+ if urls is None or len(urls) == 0:
208
+ return None
209
+ url = urls[0]
210
+ if url is None:
211
+ return None
212
+ path = url.path()
213
+ return str(path) if path else None
214
+
215
+
216
+ def pick_image_file() -> str | None:
217
+ """Open a native image picker; return the chosen path or ``None``.
218
+
219
+ Shares the activation/level fixups of :func:`pick_folder` so the panel lands
220
+ in front of the discussion window instead of behind it.
221
+ """
222
+ try:
223
+ from AppKit import NSApp, NSOpenPanel, NSPopUpMenuWindowLevel
224
+ except ImportError:
225
+ return None
226
+ NSApp.activateIgnoringOtherApps_(True)
227
+ panel = NSOpenPanel.openPanel()
228
+ panel.setCanChooseFiles_(True)
229
+ panel.setCanChooseDirectories_(False)
230
+ panel.setAllowsMultipleSelection_(False)
231
+ panel.setResolvesAliases_(True)
232
+ panel.setAllowedFileTypes_(["png", "jpg", "jpeg", "gif", "webp"])
233
+ panel.setLevel_(NSPopUpMenuWindowLevel + 1)
234
+ if panel.runModal() != 1: # NSModalResponseOK
235
+ return None
236
+ urls = panel.URLs()
237
+ if urls is None or len(urls) == 0:
238
+ return None
239
+ url = urls[0]
240
+ if url is None:
241
+ return None
242
+ path = url.path()
243
+ return str(path) if path else None
time_utils.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+
6
+ def parse_optional_iso8601_utc(value: object) -> datetime | None:
7
+ if not isinstance(value, str) or not value:
8
+ return None
9
+ try:
10
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
11
+ except ValueError:
12
+ return None
13
+ return _coerce_utc(parsed)
14
+
15
+
16
+ def parse_iso8601_utc_or_raise(value: str) -> datetime:
17
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
18
+ return _coerce_utc(parsed)
19
+
20
+
21
+ def _coerce_utc(value: datetime) -> datetime:
22
+ if value.tzinfo is None:
23
+ return value.replace(tzinfo=UTC)
24
+ return value.astimezone(UTC)