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
usage_cli.py
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
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
|
+
import csv
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import codex_loader
|
|
16
|
+
from adapters import claude, codex
|
|
17
|
+
from adapters.rate_limits import load_rate_limits as load_claude_rate_limits
|
|
18
|
+
from adapters.registry import detect_agents
|
|
19
|
+
from adapters.types import AgentInfo, RateLimits
|
|
20
|
+
from analyzer.aggregator import aggregate_daily, aggregate_monthly, aggregate_sessions, aggregate_weekly
|
|
21
|
+
from analyzer.blocks import analyze_blocks, calculate_p90
|
|
22
|
+
from analyzer import persona_loader
|
|
23
|
+
from setup_hook import is_claude_setup, is_codex_setup, is_setup, setup, unsetup
|
|
24
|
+
from session_hooks import disable_session_resume, disable_terse_mode
|
|
25
|
+
from i18n import t
|
|
26
|
+
from ui.tables import (
|
|
27
|
+
AGENT_LABEL, console, render_daily, render_dashboard,
|
|
28
|
+
render_monthly, render_sessions, render_tab_bar, render_weekly,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
AGENT_ALIASES = {"claude": "claude-code", "codex": "codex"}
|
|
32
|
+
AGENT_LOADERS = {"claude-code": claude, "codex": codex}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_codex_rate_limits() -> RateLimits | None:
|
|
36
|
+
rate_limits = codex_loader.load_rate_limits()
|
|
37
|
+
if rate_limits is None:
|
|
38
|
+
return None
|
|
39
|
+
return RateLimits(
|
|
40
|
+
five_hour_pct=rate_limits.five_hour_pct,
|
|
41
|
+
five_hour_resets_at=(
|
|
42
|
+
int(rate_limits.five_hour_resets_at)
|
|
43
|
+
if rate_limits.five_hour_resets_at is not None
|
|
44
|
+
else None
|
|
45
|
+
),
|
|
46
|
+
seven_day_pct=rate_limits.seven_day_pct,
|
|
47
|
+
seven_day_resets_at=(
|
|
48
|
+
int(rate_limits.seven_day_resets_at)
|
|
49
|
+
if rate_limits.seven_day_resets_at is not None
|
|
50
|
+
else None
|
|
51
|
+
),
|
|
52
|
+
model=rate_limits.model or "",
|
|
53
|
+
updated_at=rate_limits.updated_at,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
RATE_LIMIT_LOADERS = {"claude-code": load_claude_rate_limits, "codex": _load_codex_rate_limits}
|
|
58
|
+
|
|
59
|
+
SORT_KEYS = {
|
|
60
|
+
"tokens": ("total_tokens", True),
|
|
61
|
+
"cost": ("cost_usd", True),
|
|
62
|
+
"messages": ("message_count", True),
|
|
63
|
+
"sessions": ("session_count", True),
|
|
64
|
+
"time": None, # handled per-command
|
|
65
|
+
"input": ("input_tokens", True),
|
|
66
|
+
"output": ("output_tokens", True),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
REPORT_HELP = """Usage: usage report [--last30|--all|--today|--last7|--week|--month] [--out PATH]
|
|
70
|
+
|
|
71
|
+
Generate an HTML usage report.
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
--last30 Include the last 30 days (default)
|
|
75
|
+
--all Include all usage data
|
|
76
|
+
--today Include today only
|
|
77
|
+
--last7 Include the last 7 days
|
|
78
|
+
--week Include this week
|
|
79
|
+
--month Include this month
|
|
80
|
+
--out PATH Save to a specific path
|
|
81
|
+
-h, --help Show this help
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
EXPORT_HELP = """Usage: usage export [--daily|--weekly|--monthly|--sessions] [--out PATH]
|
|
85
|
+
|
|
86
|
+
Export aggregated usage data as CSV.
|
|
87
|
+
|
|
88
|
+
Options:
|
|
89
|
+
--daily Export daily usage (default)
|
|
90
|
+
--weekly Export weekly usage
|
|
91
|
+
--monthly Export monthly usage
|
|
92
|
+
--sessions Export session usage
|
|
93
|
+
--out PATH Save to a specific path
|
|
94
|
+
-h, --help Show this help
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
STATUS_HELP = """Usage: usage status [--json]
|
|
98
|
+
|
|
99
|
+
Show local Claude Code and Codex quota status.
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
--json Print machine-readable JSON
|
|
103
|
+
-h, --help Show this help
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
_REPORT_EXPORT_OPTIONS = {
|
|
107
|
+
"report": {
|
|
108
|
+
"-h",
|
|
109
|
+
"--help",
|
|
110
|
+
"--last30",
|
|
111
|
+
"--today",
|
|
112
|
+
"--last7",
|
|
113
|
+
"--week",
|
|
114
|
+
"--month",
|
|
115
|
+
"--all",
|
|
116
|
+
"--out",
|
|
117
|
+
},
|
|
118
|
+
"export": {
|
|
119
|
+
"-h",
|
|
120
|
+
"--help",
|
|
121
|
+
"--daily",
|
|
122
|
+
"--weekly",
|
|
123
|
+
"--monthly",
|
|
124
|
+
"--sessions",
|
|
125
|
+
"--out",
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
EXPORT_FIELDS: dict[str, list[str]] = {
|
|
130
|
+
"daily": [
|
|
131
|
+
"agent_id", "date", "input_tokens", "output_tokens",
|
|
132
|
+
"cache_creation_tokens", "cache_read_tokens", "total_tokens",
|
|
133
|
+
"cost_usd", "session_count", "message_count",
|
|
134
|
+
],
|
|
135
|
+
"weekly": [
|
|
136
|
+
"agent_id", "week", "week_start", "week_end", "input_tokens",
|
|
137
|
+
"output_tokens", "cache_creation_tokens", "cache_read_tokens",
|
|
138
|
+
"total_tokens", "cost_usd", "session_count", "message_count",
|
|
139
|
+
],
|
|
140
|
+
"monthly": [
|
|
141
|
+
"agent_id", "month", "input_tokens", "output_tokens",
|
|
142
|
+
"cache_creation_tokens", "cache_read_tokens", "total_tokens",
|
|
143
|
+
"cost_usd", "session_count", "message_count",
|
|
144
|
+
],
|
|
145
|
+
"sessions": [
|
|
146
|
+
"agent_id", "session_id", "project", "model", "start_time",
|
|
147
|
+
"end_time", "duration_minutes", "input_tokens", "output_tokens",
|
|
148
|
+
"cache_creation_tokens", "cache_read_tokens", "total_tokens",
|
|
149
|
+
"cost_usd", "message_count",
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_sort_args(args: list[str]) -> tuple[list[str], str | None, bool]:
|
|
155
|
+
"""Extract --sort KEY and --asc from args, return (remaining, sort_key, descending)."""
|
|
156
|
+
remaining = []
|
|
157
|
+
sort_key = None
|
|
158
|
+
descending = True
|
|
159
|
+
i = 0
|
|
160
|
+
while i < len(args):
|
|
161
|
+
if args[i] == "--sort" and i + 1 < len(args):
|
|
162
|
+
sort_key = args[i + 1].lower()
|
|
163
|
+
i += 2
|
|
164
|
+
elif args[i] == "--asc":
|
|
165
|
+
descending = False
|
|
166
|
+
i += 1
|
|
167
|
+
elif args[i] == "--desc":
|
|
168
|
+
descending = True
|
|
169
|
+
i += 1
|
|
170
|
+
else:
|
|
171
|
+
remaining.append(args[i])
|
|
172
|
+
i += 1
|
|
173
|
+
return remaining, sort_key, descending
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_report_args(args: list[str]) -> tuple[str, str | None, bool]:
|
|
177
|
+
period = "last30"
|
|
178
|
+
out_path, show_help = _parse_out_and_help(args, "report")
|
|
179
|
+
i = 0
|
|
180
|
+
while i < len(args):
|
|
181
|
+
arg = args[i]
|
|
182
|
+
if arg in {"-h", "--help"}:
|
|
183
|
+
pass
|
|
184
|
+
elif arg == "--last30":
|
|
185
|
+
period = "last30"
|
|
186
|
+
elif arg == "--today":
|
|
187
|
+
period = "today"
|
|
188
|
+
elif arg == "--last7":
|
|
189
|
+
period = "last7"
|
|
190
|
+
elif arg == "--week":
|
|
191
|
+
period = "week"
|
|
192
|
+
elif arg == "--month":
|
|
193
|
+
period = "month"
|
|
194
|
+
elif arg == "--all":
|
|
195
|
+
period = "all"
|
|
196
|
+
elif _is_out_arg(arg):
|
|
197
|
+
if arg == "--out":
|
|
198
|
+
i += 1
|
|
199
|
+
elif arg.startswith("-"):
|
|
200
|
+
_exit_unknown_option("report", arg)
|
|
201
|
+
else:
|
|
202
|
+
console.print(f"[red]Error:[/red] unexpected report argument: {arg}")
|
|
203
|
+
sys.exit(1)
|
|
204
|
+
i += 1
|
|
205
|
+
return period, out_path, show_help
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_export_args(args: list[str]) -> tuple[str, str | None, bool]:
|
|
209
|
+
export_type = "daily"
|
|
210
|
+
out_path, show_help = _parse_out_and_help(args, "export")
|
|
211
|
+
i = 0
|
|
212
|
+
while i < len(args):
|
|
213
|
+
arg = args[i]
|
|
214
|
+
if arg in {"-h", "--help"}:
|
|
215
|
+
pass
|
|
216
|
+
elif arg == "--daily":
|
|
217
|
+
export_type = "daily"
|
|
218
|
+
elif arg == "--weekly":
|
|
219
|
+
export_type = "weekly"
|
|
220
|
+
elif arg == "--monthly":
|
|
221
|
+
export_type = "monthly"
|
|
222
|
+
elif arg == "--sessions":
|
|
223
|
+
export_type = "sessions"
|
|
224
|
+
elif _is_out_arg(arg):
|
|
225
|
+
if arg == "--out":
|
|
226
|
+
i += 1
|
|
227
|
+
elif arg.startswith("-"):
|
|
228
|
+
_exit_unknown_option("export", arg)
|
|
229
|
+
else:
|
|
230
|
+
console.print(f"[red]Error:[/red] unexpected export argument: {arg}")
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
i += 1
|
|
233
|
+
return export_type, out_path, show_help
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _is_out_arg(arg: str) -> bool:
|
|
237
|
+
return arg == "--out" or arg.startswith("--out=")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _exit_unknown_option(command: str, arg: str) -> None:
|
|
241
|
+
console.print(f"[red]Error:[/red] unknown {command} option: {arg}")
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_out_and_help(args: list[str], command: str) -> tuple[str | None, bool]:
|
|
246
|
+
out_path = None
|
|
247
|
+
show_help = False
|
|
248
|
+
i = 0
|
|
249
|
+
while i < len(args):
|
|
250
|
+
arg = args[i]
|
|
251
|
+
if arg in {"-h", "--help"}:
|
|
252
|
+
show_help = True
|
|
253
|
+
elif arg.startswith("--out="):
|
|
254
|
+
out_path = arg[6:]
|
|
255
|
+
elif arg == "--out":
|
|
256
|
+
if i + 1 >= len(args) or args[i + 1].startswith("--"):
|
|
257
|
+
console.print("[red]Error:[/red] --out requires a path")
|
|
258
|
+
sys.exit(1)
|
|
259
|
+
out_path = args[i + 1]
|
|
260
|
+
i += 1
|
|
261
|
+
elif arg.startswith("-") and arg not in _REPORT_EXPORT_OPTIONS[command]:
|
|
262
|
+
_exit_unknown_option(command, arg)
|
|
263
|
+
i += 1
|
|
264
|
+
return out_path, show_help
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _csv_row(stat: Any, fields: list[str]) -> dict[str, Any]:
|
|
268
|
+
row: dict[str, Any] = {}
|
|
269
|
+
for field in fields:
|
|
270
|
+
value = getattr(stat, field)
|
|
271
|
+
if field in {"start_time", "end_time"}:
|
|
272
|
+
value = value.isoformat()
|
|
273
|
+
row[field] = value
|
|
274
|
+
return row
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _write_export_csv(stats: list[Any], export_type: str, out_path: str | None) -> str | None:
|
|
278
|
+
fields = EXPORT_FIELDS[export_type]
|
|
279
|
+
if out_path:
|
|
280
|
+
path = Path(out_path)
|
|
281
|
+
with path.open("w", encoding="utf-8", newline="") as fh:
|
|
282
|
+
writer = csv.DictWriter(fh, fieldnames=fields, lineterminator="\n")
|
|
283
|
+
writer.writeheader()
|
|
284
|
+
for stat in stats:
|
|
285
|
+
writer.writerow(_csv_row(stat, fields))
|
|
286
|
+
return str(path)
|
|
287
|
+
|
|
288
|
+
writer = csv.DictWriter(sys.stdout, fieldnames=fields, lineterminator="\n")
|
|
289
|
+
writer.writeheader()
|
|
290
|
+
for stat in stats:
|
|
291
|
+
writer.writerow(_csv_row(stat, fields))
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _status_agent(rate_limits: RateLimits | None) -> dict[str, Any]:
|
|
296
|
+
if rate_limits is None:
|
|
297
|
+
return {
|
|
298
|
+
"available": False,
|
|
299
|
+
"five_hour": {"used_percent": None, "resets_at": None},
|
|
300
|
+
"seven_day": {"used_percent": None, "resets_at": None},
|
|
301
|
+
"model": None,
|
|
302
|
+
"updated_at": None,
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
"available": True,
|
|
306
|
+
"five_hour": {
|
|
307
|
+
"used_percent": rate_limits.five_hour_pct,
|
|
308
|
+
"resets_at": rate_limits.five_hour_resets_at,
|
|
309
|
+
},
|
|
310
|
+
"seven_day": {
|
|
311
|
+
"used_percent": rate_limits.seven_day_pct,
|
|
312
|
+
"resets_at": rate_limits.seven_day_resets_at,
|
|
313
|
+
},
|
|
314
|
+
"model": rate_limits.model,
|
|
315
|
+
"updated_at": rate_limits.updated_at,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _status_payload() -> dict[str, Any]:
|
|
320
|
+
agents: dict[str, dict[str, Any]] = {}
|
|
321
|
+
for agent_id in ("claude-code", "codex"):
|
|
322
|
+
try:
|
|
323
|
+
rate_limits = RATE_LIMIT_LOADERS[agent_id]()
|
|
324
|
+
except Exception:
|
|
325
|
+
rate_limits = None
|
|
326
|
+
agents[agent_id] = _status_agent(rate_limits)
|
|
327
|
+
return {
|
|
328
|
+
"schema_version": 1,
|
|
329
|
+
"generated_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
|
330
|
+
"agents": agents,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _status_summary(payload: dict[str, Any]) -> str:
|
|
335
|
+
summaries = []
|
|
336
|
+
for agent_id, status in payload["agents"].items():
|
|
337
|
+
if not status["available"]:
|
|
338
|
+
summaries.append(f"{agent_id} available=false")
|
|
339
|
+
continue
|
|
340
|
+
five_hour = status["five_hour"]["used_percent"]
|
|
341
|
+
seven_day = status["seven_day"]["used_percent"]
|
|
342
|
+
five_hour_text = "?" if five_hour is None else f"{five_hour:.1f}%"
|
|
343
|
+
seven_day_text = "?" if seven_day is None else f"{seven_day:.1f}%"
|
|
344
|
+
summaries.append(f"{agent_id} 5h={five_hour_text} 7d={seven_day_text}")
|
|
345
|
+
return " | ".join(summaries)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _run_status(args: list[str]) -> None:
|
|
349
|
+
if any(arg in {"-h", "--help"} for arg in args):
|
|
350
|
+
print(STATUS_HELP)
|
|
351
|
+
return
|
|
352
|
+
unknown = next((arg for arg in args if arg != "--json"), None)
|
|
353
|
+
if unknown is not None:
|
|
354
|
+
print(f"Error: unknown status option: {unknown}", file=sys.stderr)
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
payload = _status_payload()
|
|
358
|
+
if "--json" in args:
|
|
359
|
+
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
360
|
+
else:
|
|
361
|
+
print(_status_summary(payload))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _apply_sort(stats: list[Any], sort_key: str | None, descending: bool, default_attr: str, default_reverse: bool) -> None:
|
|
365
|
+
if sort_key is None:
|
|
366
|
+
stats.sort(key=lambda s: getattr(s, default_attr), reverse=default_reverse)
|
|
367
|
+
return
|
|
368
|
+
if sort_key not in SORT_KEYS:
|
|
369
|
+
valid = ", ".join(SORT_KEYS.keys())
|
|
370
|
+
console.print(f"[yellow]{t('unknown_sort_field', field=sort_key, valid=valid)}[/yellow]")
|
|
371
|
+
stats.sort(key=lambda s: getattr(s, default_attr), reverse=default_reverse)
|
|
372
|
+
return
|
|
373
|
+
mapping = SORT_KEYS[sort_key]
|
|
374
|
+
if mapping is None:
|
|
375
|
+
stats.sort(key=lambda s: getattr(s, default_attr), reverse=descending)
|
|
376
|
+
else:
|
|
377
|
+
attr, _ = mapping
|
|
378
|
+
stats.sort(key=lambda s: getattr(s, attr), reverse=descending)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _load_entries(agent_id: str, hours_back: int = 0) -> list[Any]:
|
|
382
|
+
loader = AGENT_LOADERS.get(agent_id)
|
|
383
|
+
if loader is None:
|
|
384
|
+
return []
|
|
385
|
+
entries: list[Any] = loader.load_entries(hours_back=hours_back)
|
|
386
|
+
return entries
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _aggregate_per_agent(agents: list[AgentInfo], agg_fn: Callable[[list[Any]], list[Any]]) -> list[Any]:
|
|
390
|
+
stats: list[Any] = []
|
|
391
|
+
for a in agents:
|
|
392
|
+
entries = _load_entries(a.id)
|
|
393
|
+
for s in agg_fn(entries):
|
|
394
|
+
s.agent_id = a.id
|
|
395
|
+
stats.append(s)
|
|
396
|
+
return stats
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _show_agent_dashboard(agent_id: str) -> None:
|
|
400
|
+
agent_name = AGENT_LABEL.get(agent_id, agent_id)
|
|
401
|
+
data = _build_agent_data(agent_id, agent_name)
|
|
402
|
+
if not data:
|
|
403
|
+
console.print(f"[yellow]{t('no_token_data')}[/yellow]")
|
|
404
|
+
return
|
|
405
|
+
render_dashboard(**data)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _build_agent_data(agent_id: str, agent_name: str) -> dict[str, Any] | None:
|
|
409
|
+
entries = _load_entries(agent_id)
|
|
410
|
+
if not entries:
|
|
411
|
+
return None
|
|
412
|
+
daily = aggregate_daily(entries)
|
|
413
|
+
weekly = aggregate_weekly(entries)
|
|
414
|
+
monthly = aggregate_monthly(entries)
|
|
415
|
+
sessions = aggregate_sessions(entries)
|
|
416
|
+
from datetime import datetime, timezone, timedelta
|
|
417
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
|
|
418
|
+
recent = [e for e in entries if e.timestamp >= cutoff]
|
|
419
|
+
blocks = analyze_blocks(recent)
|
|
420
|
+
rate_limits = RATE_LIMIT_LOADERS.get(agent_id, lambda: None)()
|
|
421
|
+
p90 = None
|
|
422
|
+
has_limits = rate_limits and (rate_limits.five_hour_pct is not None or rate_limits.seven_day_pct is not None)
|
|
423
|
+
if not has_limits:
|
|
424
|
+
p90 = calculate_p90(daily)
|
|
425
|
+
session_titles = _load_session_titles()
|
|
426
|
+
return dict(
|
|
427
|
+
daily_stats=daily, weekly_stats=weekly, monthly_stats=monthly,
|
|
428
|
+
sessions=sessions, blocks=blocks, rate_limits=rate_limits,
|
|
429
|
+
p90=p90, agents=[agent_name], session_titles=session_titles,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _load_session_titles() -> dict[str, str] | None:
|
|
434
|
+
try:
|
|
435
|
+
titles = persona_loader.load_profile(30).titles_by_session
|
|
436
|
+
except Exception:
|
|
437
|
+
return None
|
|
438
|
+
return titles or None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _initial_agent_index(agents: list[AgentInfo]) -> int:
|
|
442
|
+
import os
|
|
443
|
+
|
|
444
|
+
preferred = None
|
|
445
|
+
if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_SANDBOX"):
|
|
446
|
+
preferred = "codex"
|
|
447
|
+
elif os.environ.get("CLAUDE_CONFIG_DIR") or os.environ.get("CLAUDECODE"):
|
|
448
|
+
preferred = "claude-code"
|
|
449
|
+
|
|
450
|
+
if preferred:
|
|
451
|
+
for i, agent in enumerate(agents):
|
|
452
|
+
if agent.id == preferred:
|
|
453
|
+
return i
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _fit_screen(text: str, height: int, scroll_offset: int) -> tuple[str, int]:
|
|
458
|
+
lines = text.splitlines()
|
|
459
|
+
if not lines:
|
|
460
|
+
return "", 0
|
|
461
|
+
max_body = max(1, height - 1)
|
|
462
|
+
max_scroll = max(0, len(lines) - max_body)
|
|
463
|
+
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
|
464
|
+
visible = lines[:1] + lines[1 + scroll_offset:1 + scroll_offset + max_body - 1]
|
|
465
|
+
return "\n".join(visible), max_scroll
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _dashboard_sort_cycle() -> list[tuple[str, str, str]]:
|
|
469
|
+
return [
|
|
470
|
+
("time", "start_time", t("sort_time")),
|
|
471
|
+
("tokens", "total_tokens", t("sort_token")),
|
|
472
|
+
("cost", "cost_usd", t("sort_cost")),
|
|
473
|
+
("messages", "message_count", t("sort_messages")),
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _show_interactive_dashboard(agents: list[AgentInfo]) -> None:
|
|
478
|
+
import shutil
|
|
479
|
+
from io import StringIO
|
|
480
|
+
from rich.console import Console as RichConsole
|
|
481
|
+
import ui.tables as _tables
|
|
482
|
+
|
|
483
|
+
agent_names = [a.name for a in agents]
|
|
484
|
+
current = _initial_agent_index(agents)
|
|
485
|
+
scroll_offset = 0
|
|
486
|
+
sort_idx = 0
|
|
487
|
+
sort_desc = True
|
|
488
|
+
session_limit = 30
|
|
489
|
+
orig = _tables.console
|
|
490
|
+
|
|
491
|
+
sys.stdout.write("\033[?1049h\033[?7l\033[2J\033[3J\033[H\033[?25l")
|
|
492
|
+
cache = {}
|
|
493
|
+
sort_cycle = _dashboard_sort_cycle()
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
while True:
|
|
497
|
+
agent = agents[current]
|
|
498
|
+
if agent.id not in cache:
|
|
499
|
+
sys.stdout.write(f"\033[2J\033[3J\033[H\033[2m{t('loading')}\033[0m")
|
|
500
|
+
sys.stdout.flush()
|
|
501
|
+
cache[agent.id] = _build_agent_data(agent.id, agent.name)
|
|
502
|
+
|
|
503
|
+
size = shutil.get_terminal_size((80, 24))
|
|
504
|
+
width = size.columns
|
|
505
|
+
height = size.lines
|
|
506
|
+
|
|
507
|
+
data = cache[agent.id]
|
|
508
|
+
if data:
|
|
509
|
+
_, sort_attr, sort_label = sort_cycle[sort_idx]
|
|
510
|
+
sorted_sessions = sorted(
|
|
511
|
+
data["sessions"],
|
|
512
|
+
key=lambda s: getattr(s, sort_attr),
|
|
513
|
+
reverse=sort_desc,
|
|
514
|
+
)
|
|
515
|
+
arrow = "↓" if sort_desc else "↑"
|
|
516
|
+
session_title = t("session_title", limit=session_limit, label=sort_label, arrow=arrow)
|
|
517
|
+
else:
|
|
518
|
+
sorted_sessions = []
|
|
519
|
+
session_title = None
|
|
520
|
+
|
|
521
|
+
buf = StringIO()
|
|
522
|
+
_tables.console = RichConsole(
|
|
523
|
+
file=buf, width=width, force_terminal=True,
|
|
524
|
+
)
|
|
525
|
+
render_tab_bar(agent_names, current)
|
|
526
|
+
if data:
|
|
527
|
+
render_data = {**data, "sessions": sorted_sessions}
|
|
528
|
+
render_dashboard(**render_data, session_limit=session_limit, top_margin=False, session_title=session_title)
|
|
529
|
+
else:
|
|
530
|
+
_tables.console.print(f"[yellow]{t('no_data')}[/yellow]")
|
|
531
|
+
_tables.console = orig
|
|
532
|
+
|
|
533
|
+
screen, max_scroll = _fit_screen(buf.getvalue(), height, scroll_offset)
|
|
534
|
+
sys.stdout.write("\033[2J\033[3J\033[H" + screen)
|
|
535
|
+
sys.stdout.flush()
|
|
536
|
+
|
|
537
|
+
key = _read_key()
|
|
538
|
+
if key == "left":
|
|
539
|
+
current = (current - 1) % len(agents)
|
|
540
|
+
scroll_offset = 0
|
|
541
|
+
elif key == "right":
|
|
542
|
+
current = (current + 1) % len(agents)
|
|
543
|
+
scroll_offset = 0
|
|
544
|
+
elif key == "up":
|
|
545
|
+
scroll_offset = max(0, scroll_offset - 1)
|
|
546
|
+
elif key == "down":
|
|
547
|
+
scroll_offset = min(max_scroll, scroll_offset + 1)
|
|
548
|
+
elif key == "page_up":
|
|
549
|
+
scroll_offset = max(0, scroll_offset - max(1, height - 3))
|
|
550
|
+
elif key == "page_down":
|
|
551
|
+
scroll_offset = min(max_scroll, scroll_offset + max(1, height - 3))
|
|
552
|
+
elif key == "sort":
|
|
553
|
+
sort_idx = (sort_idx + 1) % len(sort_cycle)
|
|
554
|
+
scroll_offset = 0
|
|
555
|
+
elif key == "reverse":
|
|
556
|
+
sort_desc = not sort_desc
|
|
557
|
+
elif key == "more":
|
|
558
|
+
session_limit += 10
|
|
559
|
+
elif key == "less":
|
|
560
|
+
session_limit = max(10, session_limit - 10)
|
|
561
|
+
elif key == "report":
|
|
562
|
+
import time
|
|
563
|
+
|
|
564
|
+
from analyzer.reporter import build_report_data
|
|
565
|
+
from ui.html_report import save_and_open
|
|
566
|
+
|
|
567
|
+
report_data = build_report_data(agents, "month")
|
|
568
|
+
saved = save_and_open(report_data)
|
|
569
|
+
msg = f"\033[32m✓ Report saved: {saved}\033[0m"
|
|
570
|
+
sys.stdout.write(f"\033[{height};1H\033[2K{msg}")
|
|
571
|
+
sys.stdout.flush()
|
|
572
|
+
time.sleep(2)
|
|
573
|
+
elif key == "quit":
|
|
574
|
+
break
|
|
575
|
+
finally:
|
|
576
|
+
sys.stdout.write("\033[?7h\033[?25h\033[?1049l")
|
|
577
|
+
sys.stdout.flush()
|
|
578
|
+
_tables.console = orig
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _read_key_unix() -> str:
|
|
582
|
+
# The termios/tty attribute ignores are for mypy's win32 run, where the
|
|
583
|
+
# modules exist as stubs but expose no POSIX attributes; pyproject sets
|
|
584
|
+
# warn_unused_ignores=false for this module so the darwin run tolerates
|
|
585
|
+
# them (and the msvcrt ignores below, in reverse).
|
|
586
|
+
import os as _os
|
|
587
|
+
import select
|
|
588
|
+
import tty
|
|
589
|
+
import termios
|
|
590
|
+
fd = sys.stdin.fileno()
|
|
591
|
+
old = termios.tcgetattr(fd) # type: ignore[attr-defined]
|
|
592
|
+
try:
|
|
593
|
+
tty.setraw(fd) # type: ignore[attr-defined]
|
|
594
|
+
ch = _os.read(fd, 1)
|
|
595
|
+
if ch == b"\x1b":
|
|
596
|
+
if not select.select([fd], [], [], 0.05)[0]:
|
|
597
|
+
return "quit"
|
|
598
|
+
ch2 = _os.read(fd, 1)
|
|
599
|
+
if ch2 == b"[":
|
|
600
|
+
ch3 = _os.read(fd, 1)
|
|
601
|
+
if ch3 == b"D":
|
|
602
|
+
return "left"
|
|
603
|
+
if ch3 == b"C":
|
|
604
|
+
return "right"
|
|
605
|
+
if ch3 == b"A":
|
|
606
|
+
return "up"
|
|
607
|
+
if ch3 == b"B":
|
|
608
|
+
return "down"
|
|
609
|
+
if ch3 in (b"5", b"6"):
|
|
610
|
+
if select.select([fd], [], [], 0.05)[0]:
|
|
611
|
+
_os.read(fd, 1)
|
|
612
|
+
return "page_up" if ch3 == b"5" else "page_down"
|
|
613
|
+
return "other"
|
|
614
|
+
if ch == b"h":
|
|
615
|
+
return "left"
|
|
616
|
+
if ch == b"l":
|
|
617
|
+
return "right"
|
|
618
|
+
if ch == b"k":
|
|
619
|
+
return "up"
|
|
620
|
+
if ch == b"j":
|
|
621
|
+
return "down"
|
|
622
|
+
if ch == b"b":
|
|
623
|
+
return "page_up"
|
|
624
|
+
if ch == b"f":
|
|
625
|
+
return "page_down"
|
|
626
|
+
if ch == b"s":
|
|
627
|
+
return "sort"
|
|
628
|
+
if ch == b"r":
|
|
629
|
+
return "reverse"
|
|
630
|
+
if ch == b"e":
|
|
631
|
+
return "report"
|
|
632
|
+
if ch in (b"+", b"="):
|
|
633
|
+
return "more"
|
|
634
|
+
if ch in (b"-", b"_"):
|
|
635
|
+
return "less"
|
|
636
|
+
if ch in (b"q", b"Q", b"\x03"):
|
|
637
|
+
return "quit"
|
|
638
|
+
return "other"
|
|
639
|
+
finally:
|
|
640
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old) # type: ignore[attr-defined]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _read_key_win() -> str:
|
|
644
|
+
import msvcrt
|
|
645
|
+
ch = msvcrt.getch() # type: ignore[attr-defined]
|
|
646
|
+
if ch in (b"\xe0", b"\x00"):
|
|
647
|
+
ch2 = msvcrt.getch() # type: ignore[attr-defined]
|
|
648
|
+
if ch2 == b"K":
|
|
649
|
+
return "left"
|
|
650
|
+
if ch2 == b"M":
|
|
651
|
+
return "right"
|
|
652
|
+
if ch2 == b"H":
|
|
653
|
+
return "up"
|
|
654
|
+
if ch2 == b"P":
|
|
655
|
+
return "down"
|
|
656
|
+
if ch2 == b"I":
|
|
657
|
+
return "page_up"
|
|
658
|
+
if ch2 == b"Q":
|
|
659
|
+
return "page_down"
|
|
660
|
+
return "other"
|
|
661
|
+
if ch == b"h":
|
|
662
|
+
return "left"
|
|
663
|
+
if ch == b"l":
|
|
664
|
+
return "right"
|
|
665
|
+
if ch == b"k":
|
|
666
|
+
return "up"
|
|
667
|
+
if ch == b"j":
|
|
668
|
+
return "down"
|
|
669
|
+
if ch == b"b":
|
|
670
|
+
return "page_up"
|
|
671
|
+
if ch == b"f":
|
|
672
|
+
return "page_down"
|
|
673
|
+
if ch == b"e":
|
|
674
|
+
return "report"
|
|
675
|
+
if ch in (b"q", b"Q", b"\x03", b"\x1b"):
|
|
676
|
+
return "quit"
|
|
677
|
+
return "other"
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
_read_key = _read_key_win if sys.platform == "win32" else _read_key_unix
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _get_version() -> str:
|
|
684
|
+
from importlib.metadata import version
|
|
685
|
+
return version("usage-cli")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def main() -> None:
|
|
689
|
+
args = sys.argv[1:]
|
|
690
|
+
command = args[0] if args else "dashboard"
|
|
691
|
+
|
|
692
|
+
if command in ("--version", "-v", "-V"):
|
|
693
|
+
print(f"usage {_get_version()}")
|
|
694
|
+
return
|
|
695
|
+
if command == "report" and any(arg in {"-h", "--help"} for arg in args[1:]):
|
|
696
|
+
console.print(REPORT_HELP)
|
|
697
|
+
return
|
|
698
|
+
if command == "export" and any(arg in {"-h", "--help"} for arg in args[1:]):
|
|
699
|
+
console.print(EXPORT_HELP)
|
|
700
|
+
return
|
|
701
|
+
if command == "status":
|
|
702
|
+
_run_status(args[1:])
|
|
703
|
+
return
|
|
704
|
+
if command == "setup":
|
|
705
|
+
setup()
|
|
706
|
+
import session_hooks
|
|
707
|
+
|
|
708
|
+
session_hooks._migrate_bundled_python_commands_if_needed()
|
|
709
|
+
return
|
|
710
|
+
if command == "unsetup":
|
|
711
|
+
disable_session_resume()
|
|
712
|
+
disable_terse_mode()
|
|
713
|
+
unsetup()
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
agents = detect_agents()
|
|
717
|
+
if not agents:
|
|
718
|
+
console.print(f"[red]{t('no_agent')}[/red]")
|
|
719
|
+
sys.exit(1)
|
|
720
|
+
|
|
721
|
+
agent_ids = {a.id for a in agents}
|
|
722
|
+
|
|
723
|
+
if command not in {"dashboard", "export"}:
|
|
724
|
+
console.print(f"[dim]{t('detected', agents=', '.join(a.name + ' ✓' for a in agents))}[/dim]")
|
|
725
|
+
|
|
726
|
+
hook_warning_needed = (
|
|
727
|
+
(command == "claude" and not is_claude_setup())
|
|
728
|
+
or (command == "codex" and not is_codex_setup())
|
|
729
|
+
or (command not in AGENT_ALIASES and command != "export" and not is_setup())
|
|
730
|
+
)
|
|
731
|
+
if hook_warning_needed:
|
|
732
|
+
console.print(f"[yellow]{t('hook_not_installed')}[/yellow]")
|
|
733
|
+
|
|
734
|
+
# usage claude / usage codex
|
|
735
|
+
if command in AGENT_ALIASES:
|
|
736
|
+
agent_id = AGENT_ALIASES[command]
|
|
737
|
+
if agent_id not in agent_ids:
|
|
738
|
+
console.print(f"[red]{t('agent_not_found', name=command)}[/red]")
|
|
739
|
+
sys.exit(1)
|
|
740
|
+
_show_agent_dashboard(agent_id)
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
if command == "dashboard":
|
|
744
|
+
agent_filter = args[1] if len(args) > 1 and args[1] in AGENT_ALIASES else None
|
|
745
|
+
if agent_filter:
|
|
746
|
+
agent_id = AGENT_ALIASES[agent_filter]
|
|
747
|
+
if agent_id not in agent_ids:
|
|
748
|
+
console.print(f"[red]{t('agent_not_found', name=agent_filter)}[/red]")
|
|
749
|
+
sys.exit(1)
|
|
750
|
+
_show_agent_dashboard(agent_id)
|
|
751
|
+
elif len(agents) > 1 and sys.stdin.isatty():
|
|
752
|
+
_show_interactive_dashboard(agents)
|
|
753
|
+
else:
|
|
754
|
+
_show_agent_dashboard(agents[0].id)
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
# 其他命令使用合并数据
|
|
758
|
+
agent_names = [a.name for a in agents]
|
|
759
|
+
rest_args, sort_key, sort_desc = _parse_sort_args(args[1:])
|
|
760
|
+
|
|
761
|
+
if command == "report":
|
|
762
|
+
period, out_path, show_help = _parse_report_args(args[1:])
|
|
763
|
+
if show_help:
|
|
764
|
+
console.print(REPORT_HELP)
|
|
765
|
+
return
|
|
766
|
+
from analyzer.reporter import build_report_data
|
|
767
|
+
from ui.html_report import save_and_open
|
|
768
|
+
|
|
769
|
+
data = build_report_data(agents, period)
|
|
770
|
+
saved = save_and_open(data, out_path)
|
|
771
|
+
console.print(f"[green]✓[/green] Report saved: {saved}")
|
|
772
|
+
elif command == "export":
|
|
773
|
+
export_type, out_path, show_help = _parse_export_args(args[1:])
|
|
774
|
+
if show_help:
|
|
775
|
+
console.print(EXPORT_HELP)
|
|
776
|
+
return
|
|
777
|
+
if export_type == "daily":
|
|
778
|
+
stats = _aggregate_per_agent(agents, aggregate_daily)
|
|
779
|
+
stats.sort(key=lambda s: s.date, reverse=True)
|
|
780
|
+
elif export_type == "weekly":
|
|
781
|
+
stats = _aggregate_per_agent(agents, aggregate_weekly)
|
|
782
|
+
stats.sort(key=lambda s: s.week, reverse=True)
|
|
783
|
+
elif export_type == "monthly":
|
|
784
|
+
stats = _aggregate_per_agent(agents, aggregate_monthly)
|
|
785
|
+
stats.sort(key=lambda s: s.month, reverse=True)
|
|
786
|
+
else:
|
|
787
|
+
stats = _aggregate_per_agent(agents, aggregate_sessions)
|
|
788
|
+
stats.sort(key=lambda s: s.start_time, reverse=True)
|
|
789
|
+
|
|
790
|
+
export_saved = _write_export_csv(stats, export_type, out_path)
|
|
791
|
+
if export_saved is not None:
|
|
792
|
+
console.print(f"[green]✓[/green] Export saved: {export_saved}")
|
|
793
|
+
elif command == "daily":
|
|
794
|
+
stats = _aggregate_per_agent(agents, aggregate_daily)
|
|
795
|
+
default_attr = "date" if sort_key == "time" else "total_tokens"
|
|
796
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
|
|
797
|
+
render_daily(stats, agents=agent_names)
|
|
798
|
+
elif command == "weekly":
|
|
799
|
+
stats = _aggregate_per_agent(agents, aggregate_weekly)
|
|
800
|
+
default_attr = "week"
|
|
801
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
|
|
802
|
+
render_weekly(stats, agents=agent_names)
|
|
803
|
+
elif command == "monthly":
|
|
804
|
+
stats = _aggregate_per_agent(agents, aggregate_monthly)
|
|
805
|
+
default_attr = "month"
|
|
806
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=False)
|
|
807
|
+
render_monthly(stats, agents=agent_names)
|
|
808
|
+
elif command == "sessions":
|
|
809
|
+
limit = 20
|
|
810
|
+
for a in rest_args:
|
|
811
|
+
try:
|
|
812
|
+
limit = int(a)
|
|
813
|
+
break
|
|
814
|
+
except ValueError:
|
|
815
|
+
pass
|
|
816
|
+
stats = _aggregate_per_agent(agents, aggregate_sessions)
|
|
817
|
+
default_attr = "start_time"
|
|
818
|
+
_apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
|
|
819
|
+
render_sessions(stats, limit)
|
|
820
|
+
else:
|
|
821
|
+
console.print(f"[red]{t('unknown_cmd', cmd=command)}[/red]")
|
|
822
|
+
console.print(f"[dim]{t('available_cmds')}[/dim]")
|
|
823
|
+
sys.exit(1)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
if __name__ == "__main__":
|
|
827
|
+
main()
|