tokenmaxxing 0.1.0__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.
- tokenmaxxing/__init__.py +7 -0
- tokenmaxxing/__main__.py +6 -0
- tokenmaxxing/app.py +956 -0
- tokenmaxxing-0.1.0.dist-info/METADATA +171 -0
- tokenmaxxing-0.1.0.dist-info/RECORD +8 -0
- tokenmaxxing-0.1.0.dist-info/WHEEL +4 -0
- tokenmaxxing-0.1.0.dist-info/entry_points.txt +2 -0
- tokenmaxxing-0.1.0.dist-info/licenses/LICENSE +201 -0
tokenmaxxing/__init__.py
ADDED
tokenmaxxing/__main__.py
ADDED
tokenmaxxing/app.py
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Session Monitor — menu bar app.
|
|
3
|
+
|
|
4
|
+
Sits in the menu bar (left of the clock) and shows a progress bar for your
|
|
5
|
+
Claude Code 5-hour session OR rolling 7-day usage. Click for details and
|
|
6
|
+
to switch which window the bar tracks.
|
|
7
|
+
|
|
8
|
+
Polls Anthropic's OAuth usage endpoint directly — same call Claude Code
|
|
9
|
+
uses internally. Authoritative numbers, zero tokens consumed per poll.
|
|
10
|
+
|
|
11
|
+
GET https://api.anthropic.com/api/oauth/usage
|
|
12
|
+
Authorization: Bearer <token from system keychain or ~/.claude/.credentials.json>
|
|
13
|
+
anthropic-beta: oauth-2025-04-20
|
|
14
|
+
|
|
15
|
+
Install:
|
|
16
|
+
pip install rumps
|
|
17
|
+
Run:
|
|
18
|
+
python3 claude_limit_app.py
|
|
19
|
+
Detached:
|
|
20
|
+
nohup python3 claude_limit_app.py >/dev/null 2>&1 &
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shlex
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
import rumps
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from AppKit import (
|
|
43
|
+
NSColor,
|
|
44
|
+
NSMutableAttributedString,
|
|
45
|
+
NSForegroundColorAttributeName,
|
|
46
|
+
)
|
|
47
|
+
_HAS_APPKIT = True
|
|
48
|
+
except ImportError: # pragma: no cover
|
|
49
|
+
_HAS_APPKIT = False
|
|
50
|
+
|
|
51
|
+
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
52
|
+
OAUTH_API_VERSION = "oauth-2025-04-20"
|
|
53
|
+
KEYCHAIN_SERVICE = "Claude Code-credentials"
|
|
54
|
+
CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
|
|
55
|
+
DASHBOARD_URL = "https://claude.ai/settings/usage"
|
|
56
|
+
ENV_DASHBOARD_URL = "CLAUDE_DASHBOARD_URL"
|
|
57
|
+
|
|
58
|
+
# App UI
|
|
59
|
+
APP_NAME = "Claude"
|
|
60
|
+
LOADING_TEXT = "loading…"
|
|
61
|
+
TIMER_INTERVAL = 1
|
|
62
|
+
|
|
63
|
+
REFRESH_SECONDS = 300 # 5 min: OAuth usage endpoint has aggressive undocumented rate limits
|
|
64
|
+
REFRESH_MAX_BACKOFF = 1800 # Max backoff: 30 minutes
|
|
65
|
+
BACKOFF_EXP_CAP = 11 # 2^11 = 2048s, lets REFRESH_MAX_BACKOFF (1800s) become the binding cap
|
|
66
|
+
HTTP_TIMEOUT = 10
|
|
67
|
+
KEYCHAIN_TIMEOUT = 3
|
|
68
|
+
HTTP_STATUS_RATE_LIMITED = 429
|
|
69
|
+
HTTP_STATUS_UNAUTHORIZED = 401
|
|
70
|
+
BAR_WIDTH = 5
|
|
71
|
+
|
|
72
|
+
# Time units (seconds)
|
|
73
|
+
SECONDS_PER_DAY = 86400
|
|
74
|
+
SECONDS_PER_HOUR = 3600
|
|
75
|
+
SECONDS_PER_MINUTE = 60
|
|
76
|
+
|
|
77
|
+
# UI formatting
|
|
78
|
+
SEPARATOR = " · "
|
|
79
|
+
UNAVAILABLE = "—"
|
|
80
|
+
MARKER_SELECTED = "✓ "
|
|
81
|
+
MARKER_UNSELECTED = " "
|
|
82
|
+
STATUS_RATE_LIMITED = " [rate limited]"
|
|
83
|
+
STATUS_STALE = " (stale)"
|
|
84
|
+
MENU_ICON = "◌ "
|
|
85
|
+
|
|
86
|
+
# Menu item labels
|
|
87
|
+
EXTRA_USAGE_LABEL = "Extra usage: "
|
|
88
|
+
PLAN_LABEL = "Plan: "
|
|
89
|
+
UPDATE_LABEL = "Updated: "
|
|
90
|
+
|
|
91
|
+
# View status strings
|
|
92
|
+
VIEW_UNAVAILABLE = "n/a"
|
|
93
|
+
NEVER_UPDATED = "never"
|
|
94
|
+
EXTRA_USAGE_OFF = "off"
|
|
95
|
+
EXTRA_USAGE_ENABLED = "enabled"
|
|
96
|
+
|
|
97
|
+
# Status emojis
|
|
98
|
+
EMOJI_UNKNOWN = "⚪"
|
|
99
|
+
EMOJI_OK = "🟢"
|
|
100
|
+
EMOJI_WARN = "🟡"
|
|
101
|
+
EMOJI_CRITICAL = "🔴"
|
|
102
|
+
|
|
103
|
+
# Color thresholds (utilization %)
|
|
104
|
+
WARN_PCT = 50.0 # green → yellow
|
|
105
|
+
CRIT_PCT = 80.0 # yellow → red
|
|
106
|
+
|
|
107
|
+
# Preference menu labels
|
|
108
|
+
PREF_LABEL_RESET_TIME = 'Show "resets at Fri 07:00"'
|
|
109
|
+
PREF_LABEL_ELAPSED_TIME = 'Show "resets in 4h10m"'
|
|
110
|
+
PREF_LABEL_HISTORY = "Show 7-day history"
|
|
111
|
+
PREF_LABEL_LAUNCH_AT_STARTUP = "Launch at login"
|
|
112
|
+
|
|
113
|
+
# LaunchAgent (auto-start at user login)
|
|
114
|
+
LAUNCH_AGENT_LABEL = "com.tokenmaxxing.app"
|
|
115
|
+
LAUNCH_AGENT_DIR = Path.home() / "Library" / "LaunchAgents"
|
|
116
|
+
LAUNCH_AGENT_FILE = LAUNCH_AGENT_DIR / f"{LAUNCH_AGENT_LABEL}.plist"
|
|
117
|
+
LAUNCH_AGENT_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
|
|
118
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
119
|
+
<plist version="1.0">
|
|
120
|
+
<dict>
|
|
121
|
+
<key>Label</key>
|
|
122
|
+
<string>{label}</string>
|
|
123
|
+
<key>ProgramArguments</key>
|
|
124
|
+
<array>
|
|
125
|
+
{program_args}
|
|
126
|
+
</array>
|
|
127
|
+
<key>RunAtLoad</key>
|
|
128
|
+
<true/>
|
|
129
|
+
<key>KeepAlive</key>
|
|
130
|
+
<false/>
|
|
131
|
+
<key>ProcessType</key>
|
|
132
|
+
<string>Interactive</string>
|
|
133
|
+
<key>StandardOutPath</key>
|
|
134
|
+
<string>/tmp/tokenmaxxing.out</string>
|
|
135
|
+
<key>StandardErrorPath</key>
|
|
136
|
+
<string>/tmp/tokenmaxxing.err</string>
|
|
137
|
+
</dict>
|
|
138
|
+
</plist>
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# Time display labels
|
|
142
|
+
TIME_DISPLAY_RESETS = "resets at "
|
|
143
|
+
TIME_DISPLAY_IN = "in "
|
|
144
|
+
UPDATE_TIME_FORMAT = "%H:%M:%S"
|
|
145
|
+
DETAIL_ITEM_SPACER = " " # spacing between sections in detail list items
|
|
146
|
+
|
|
147
|
+
# Plan tier mappings
|
|
148
|
+
SUB_TYPE_MAP = {"pro": "Claude Pro", "max": "Claude Max", "free": "Free"}
|
|
149
|
+
RATE_LIMIT_TIER_MAP = {"tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3", "tier4": "Tier 4"}
|
|
150
|
+
|
|
151
|
+
# (label, key in usage payload, default visible in detail list)
|
|
152
|
+
VIEWS = [
|
|
153
|
+
("Session (5h)", "five_hour"),
|
|
154
|
+
("Weekly (7d)", "seven_day"),
|
|
155
|
+
("Weekly Sonnet", "seven_day_sonnet"),
|
|
156
|
+
]
|
|
157
|
+
VIEW_LABEL = {key: label for label, key in VIEWS}
|
|
158
|
+
VIEW_PREFIX = {
|
|
159
|
+
"five_hour": "Session ",
|
|
160
|
+
"seven_day": "Weekly ",
|
|
161
|
+
"seven_day_sonnet": "Weekly Sonnet ",
|
|
162
|
+
}
|
|
163
|
+
DEFAULT_VIEW = "five_hour"
|
|
164
|
+
|
|
165
|
+
# Per-user app data dir (history + launcher wrapper script)
|
|
166
|
+
APP_DATA_DIR = Path.home() / ".tokenmaxxing"
|
|
167
|
+
LAUNCHER_SCRIPT = APP_DATA_DIR / "tokenmaxxing" # wrapper named "tokenmaxxing" so Login Items shows the friendly name
|
|
168
|
+
|
|
169
|
+
# History feature
|
|
170
|
+
HISTORY_DIR = APP_DATA_DIR
|
|
171
|
+
HISTORY_FILE = HISTORY_DIR / "history.json"
|
|
172
|
+
HISTORY_MAX_ENTRIES = 2000
|
|
173
|
+
HISTORY_DEDUPE_SECONDS = 300 # skip identical snapshot if <5min since last
|
|
174
|
+
HISTORY_BUCKETS = 7 # one per day for the 7-day sparkline
|
|
175
|
+
HISTORY_KEY_BY_VIEW = {
|
|
176
|
+
"five_hour": "session_pct",
|
|
177
|
+
"seven_day": "weekly_pct",
|
|
178
|
+
"seven_day_sonnet": "weekly_sonnet_pct",
|
|
179
|
+
}
|
|
180
|
+
SPARK_CHARS = "▁▂▃▄▅▆▇█"
|
|
181
|
+
SPARK_GAP = " "
|
|
182
|
+
TREND_UP = "↑"
|
|
183
|
+
TREND_DOWN = "↓"
|
|
184
|
+
TREND_FLAT = "→"
|
|
185
|
+
TREND_THRESHOLD = 3.0 # pct points
|
|
186
|
+
HISTORY_LABEL = "7d: "
|
|
187
|
+
HISTORY_COLLECTING = "7d: collecting…"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _load_history() -> list:
|
|
191
|
+
"""Read the on-disk history file. Returns [] on missing/corrupt file."""
|
|
192
|
+
if not HISTORY_FILE.exists():
|
|
193
|
+
return []
|
|
194
|
+
try:
|
|
195
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
196
|
+
data = json.load(f)
|
|
197
|
+
if isinstance(data, list):
|
|
198
|
+
return data[-HISTORY_MAX_ENTRIES:]
|
|
199
|
+
print(f"history: {HISTORY_FILE} not a list; starting fresh", file=sys.stderr)
|
|
200
|
+
return []
|
|
201
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
202
|
+
print(f"history: load failed ({e}); starting fresh", file=sys.stderr)
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _save_history_atomic(entries: list) -> None:
|
|
207
|
+
"""Write entries to HISTORY_FILE atomically via tempfile + os.replace."""
|
|
208
|
+
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
fd, tmp_path = tempfile.mkstemp(dir=str(HISTORY_DIR), prefix=".history.", suffix=".tmp")
|
|
210
|
+
try:
|
|
211
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
212
|
+
json.dump(entries, f)
|
|
213
|
+
os.replace(tmp_path, str(HISTORY_FILE))
|
|
214
|
+
except Exception:
|
|
215
|
+
try:
|
|
216
|
+
os.unlink(tmp_path)
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _launchagent_installed() -> bool:
|
|
223
|
+
"""True iff the LaunchAgent plist is on disk."""
|
|
224
|
+
return LAUNCH_AGENT_FILE.exists()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _resolve_python_invocation() -> list:
|
|
228
|
+
"""Build the python argv for launching tokenmaxxing.
|
|
229
|
+
|
|
230
|
+
Adapts to how the app is currently being run:
|
|
231
|
+
- installed via pip (`__package__` set): python -m tokenmaxxing
|
|
232
|
+
- standalone script: python /path/to/app.py
|
|
233
|
+
"""
|
|
234
|
+
if __package__:
|
|
235
|
+
return [sys.executable, "-m", "tokenmaxxing"]
|
|
236
|
+
return [sys.executable, str(Path(__file__).resolve())]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _write_launcher_script() -> Path:
|
|
240
|
+
"""Write a wrapper script literally named "tokenmaxxing" so
|
|
241
|
+
System Settings → Login Items shows the friendly name instead of "python3.9"."""
|
|
242
|
+
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
quoted = " ".join(shlex.quote(a) for a in _resolve_python_invocation())
|
|
244
|
+
LAUNCHER_SCRIPT.write_text(f"#!/bin/sh\nexec {quoted}\n", encoding="utf-8")
|
|
245
|
+
LAUNCHER_SCRIPT.chmod(0o755)
|
|
246
|
+
return LAUNCHER_SCRIPT
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _install_launchagent() -> None:
|
|
250
|
+
"""Write the LaunchAgent plist so tokenmaxxing starts on next login.
|
|
251
|
+
|
|
252
|
+
We deliberately do NOT `launchctl load` here — that would spawn a duplicate
|
|
253
|
+
instance immediately while the user already has one running. The plist is
|
|
254
|
+
auto-loaded by launchd on next user login.
|
|
255
|
+
"""
|
|
256
|
+
LAUNCH_AGENT_DIR.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
launcher = _write_launcher_script()
|
|
258
|
+
program_args = f" <string>{launcher}</string>"
|
|
259
|
+
plist = LAUNCH_AGENT_PLIST.format(label=LAUNCH_AGENT_LABEL, program_args=program_args)
|
|
260
|
+
LAUNCH_AGENT_FILE.write_text(plist, encoding="utf-8")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _uninstall_launchagent() -> None:
|
|
264
|
+
"""Unload (if loaded) and delete the LaunchAgent plist."""
|
|
265
|
+
if not LAUNCH_AGENT_FILE.exists():
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
subprocess.run(
|
|
269
|
+
["launchctl", "unload", str(LAUNCH_AGENT_FILE)],
|
|
270
|
+
check=False, capture_output=True, timeout=5,
|
|
271
|
+
)
|
|
272
|
+
except (OSError, subprocess.SubprocessError):
|
|
273
|
+
pass
|
|
274
|
+
try:
|
|
275
|
+
LAUNCH_AGENT_FILE.unlink()
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _extract_pcts(payload: Optional[dict]):
|
|
281
|
+
"""Extract (session_pct, weekly_pct, weekly_sonnet_pct) from a usage payload."""
|
|
282
|
+
def _pct(view):
|
|
283
|
+
if isinstance(view, dict):
|
|
284
|
+
v = view.get("utilization")
|
|
285
|
+
if v is not None:
|
|
286
|
+
try:
|
|
287
|
+
return float(v)
|
|
288
|
+
except (TypeError, ValueError):
|
|
289
|
+
return None
|
|
290
|
+
return None
|
|
291
|
+
if not isinstance(payload, dict):
|
|
292
|
+
return None, None, None
|
|
293
|
+
return (_pct(payload.get("five_hour")),
|
|
294
|
+
_pct(payload.get("seven_day")),
|
|
295
|
+
_pct(payload.get("seven_day_sonnet")))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _bucket_snapshots(snapshots: list, key: str, now_ts: int):
|
|
299
|
+
"""Return HISTORY_BUCKETS daily buckets ending at now_ts; each is a list of pct floats."""
|
|
300
|
+
bucket_secs = SECONDS_PER_DAY
|
|
301
|
+
cutoff = now_ts - HISTORY_BUCKETS * bucket_secs
|
|
302
|
+
buckets = [[] for _ in range(HISTORY_BUCKETS)]
|
|
303
|
+
for s in snapshots:
|
|
304
|
+
ts = s.get("ts") if isinstance(s, dict) else None
|
|
305
|
+
if not isinstance(ts, (int, float)) or ts < cutoff or ts > now_ts:
|
|
306
|
+
continue
|
|
307
|
+
idx = int((ts - cutoff) / bucket_secs)
|
|
308
|
+
idx = max(0, min(HISTORY_BUCKETS - 1, idx))
|
|
309
|
+
v = s.get(key)
|
|
310
|
+
if v is None:
|
|
311
|
+
continue
|
|
312
|
+
try:
|
|
313
|
+
buckets[idx].append(float(v))
|
|
314
|
+
except (TypeError, ValueError):
|
|
315
|
+
continue
|
|
316
|
+
return buckets
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _render_sparkline(snapshots: list, key: str, now_ts: int) -> str:
|
|
320
|
+
"""Render last 7 daily buckets as a sparkline; trims leading empty buckets."""
|
|
321
|
+
buckets = _bucket_snapshots(snapshots, key, now_ts)
|
|
322
|
+
first_with_data = next((i for i, b in enumerate(buckets) if b), None)
|
|
323
|
+
if first_with_data is None:
|
|
324
|
+
return ""
|
|
325
|
+
chars = []
|
|
326
|
+
for b in buckets[first_with_data:]:
|
|
327
|
+
if not b:
|
|
328
|
+
chars.append(SPARK_GAP)
|
|
329
|
+
continue
|
|
330
|
+
avg = max(0.0, min(100.0, sum(b) / len(b)))
|
|
331
|
+
idx = int(avg / 100.0 * (len(SPARK_CHARS) - 1) + 0.5)
|
|
332
|
+
idx = max(0, min(len(SPARK_CHARS) - 1, idx))
|
|
333
|
+
chars.append(SPARK_CHARS[idx])
|
|
334
|
+
return "".join(chars)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _compute_trend(snapshots: list, key: str, now_ts: int) -> str:
|
|
338
|
+
"""Compare avg of last 24h vs prior 24h. Returns ↑/↓/→, or '' if insufficient data."""
|
|
339
|
+
last_start = now_ts - SECONDS_PER_DAY
|
|
340
|
+
prior_start = now_ts - 2 * SECONDS_PER_DAY
|
|
341
|
+
last_vals = []
|
|
342
|
+
prior_vals = []
|
|
343
|
+
for s in snapshots:
|
|
344
|
+
if not isinstance(s, dict):
|
|
345
|
+
continue
|
|
346
|
+
ts = s.get("ts")
|
|
347
|
+
v = s.get(key)
|
|
348
|
+
if not isinstance(ts, (int, float)) or v is None:
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
v = float(v)
|
|
352
|
+
except (TypeError, ValueError):
|
|
353
|
+
continue
|
|
354
|
+
if last_start <= ts <= now_ts:
|
|
355
|
+
last_vals.append(v)
|
|
356
|
+
elif prior_start <= ts < last_start:
|
|
357
|
+
prior_vals.append(v)
|
|
358
|
+
if not last_vals or not prior_vals:
|
|
359
|
+
return ""
|
|
360
|
+
delta = sum(last_vals) / len(last_vals) - sum(prior_vals) / len(prior_vals)
|
|
361
|
+
if delta > TREND_THRESHOLD:
|
|
362
|
+
return TREND_UP
|
|
363
|
+
if delta < -TREND_THRESHOLD:
|
|
364
|
+
return TREND_DOWN
|
|
365
|
+
return TREND_FLAT
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _compute_min_max_avg(snapshots: list, key: str, now_ts: int):
|
|
369
|
+
"""Return (min, avg, max) over last 7 days, or (None, None, None) if no data."""
|
|
370
|
+
cutoff = now_ts - HISTORY_BUCKETS * SECONDS_PER_DAY
|
|
371
|
+
vals = []
|
|
372
|
+
for s in snapshots:
|
|
373
|
+
if not isinstance(s, dict):
|
|
374
|
+
continue
|
|
375
|
+
ts = s.get("ts")
|
|
376
|
+
v = s.get(key)
|
|
377
|
+
if not isinstance(ts, (int, float)) or v is None or ts < cutoff or ts > now_ts:
|
|
378
|
+
continue
|
|
379
|
+
try:
|
|
380
|
+
vals.append(float(v))
|
|
381
|
+
except (TypeError, ValueError):
|
|
382
|
+
continue
|
|
383
|
+
if not vals:
|
|
384
|
+
return None, None, None
|
|
385
|
+
return min(vals), sum(vals) / len(vals), max(vals)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _get_oauth_data() -> Optional[dict]:
|
|
389
|
+
"""Read Claude Code's OAuth credentials from keychain or file. Returns the full claudeAiOauth dict."""
|
|
390
|
+
user = os.environ.get("USER", "")
|
|
391
|
+
try:
|
|
392
|
+
out = subprocess.check_output(
|
|
393
|
+
["security", "find-generic-password",
|
|
394
|
+
"-s", KEYCHAIN_SERVICE, "-a", user, "-w"],
|
|
395
|
+
stderr=subprocess.DEVNULL,
|
|
396
|
+
timeout=KEYCHAIN_TIMEOUT,
|
|
397
|
+
)
|
|
398
|
+
data = json.loads(out)
|
|
399
|
+
oauth_data = data.get("claudeAiOauth")
|
|
400
|
+
if oauth_data:
|
|
401
|
+
return oauth_data
|
|
402
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
|
|
403
|
+
json.JSONDecodeError):
|
|
404
|
+
pass
|
|
405
|
+
if CREDENTIALS_FILE.exists():
|
|
406
|
+
try:
|
|
407
|
+
with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
|
|
408
|
+
data = json.load(f)
|
|
409
|
+
return data.get("claudeAiOauth")
|
|
410
|
+
except (OSError, json.JSONDecodeError):
|
|
411
|
+
return None
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def get_access_token() -> Optional[str]:
|
|
416
|
+
"""Read Claude Code's OAuth access token. Keychain wins; file is fallback."""
|
|
417
|
+
oauth_data = _get_oauth_data()
|
|
418
|
+
return oauth_data.get("accessToken") if oauth_data else None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def fetch_usage(oauth_data: Optional[dict] = None):
|
|
422
|
+
"""Returns (payload_dict, error_str, is_rate_limited). One of payload/error is always None."""
|
|
423
|
+
token = oauth_data.get("accessToken") if oauth_data else None
|
|
424
|
+
if not token:
|
|
425
|
+
return None, "no Claude Code token", False
|
|
426
|
+
req = urllib.request.Request(
|
|
427
|
+
USAGE_URL,
|
|
428
|
+
headers={
|
|
429
|
+
"Authorization": f"Bearer {token}",
|
|
430
|
+
"anthropic-beta": OAUTH_API_VERSION,
|
|
431
|
+
"User-Agent": "claude-limit-app/1.0",
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
try:
|
|
435
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
|
|
436
|
+
return json.loads(resp.read().decode("utf-8")), None, False
|
|
437
|
+
except urllib.error.HTTPError as e:
|
|
438
|
+
if e.code == HTTP_STATUS_RATE_LIMITED:
|
|
439
|
+
return None, "rate limited — data is stale, retrying soon", True
|
|
440
|
+
if e.code == HTTP_STATUS_UNAUTHORIZED:
|
|
441
|
+
return None, "auth expired (run `claude` to refresh)", False
|
|
442
|
+
return None, f"HTTP {e.code}", False
|
|
443
|
+
except urllib.error.URLError as e:
|
|
444
|
+
return None, f"net: {e.reason}", False
|
|
445
|
+
except Exception as e: # noqa: BLE001
|
|
446
|
+
return None, str(e), False
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def bar(pct: Optional[float], width: int = BAR_WIDTH) -> str:
|
|
450
|
+
"""Solid Unicode progress bar."""
|
|
451
|
+
if pct is None:
|
|
452
|
+
return "─" * width
|
|
453
|
+
pct = max(0.0, min(100.0, float(pct)))
|
|
454
|
+
full = int(round(pct / 100.0 * width))
|
|
455
|
+
return "█" * full + "░" * (width - full)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def status_emoji(pct: Optional[float]) -> str:
|
|
459
|
+
"""Colored circle matching the title-bar tint."""
|
|
460
|
+
if pct is None:
|
|
461
|
+
return EMOJI_UNKNOWN
|
|
462
|
+
if pct < WARN_PCT:
|
|
463
|
+
return EMOJI_OK
|
|
464
|
+
if pct < CRIT_PCT:
|
|
465
|
+
return EMOJI_WARN
|
|
466
|
+
return EMOJI_CRITICAL
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def status_color(pct: Optional[float]):
|
|
470
|
+
"""NSColor for the menu-bar bar segment. Adapts to light/dark mode."""
|
|
471
|
+
if not _HAS_APPKIT:
|
|
472
|
+
return None
|
|
473
|
+
if pct is None:
|
|
474
|
+
return NSColor.labelColor()
|
|
475
|
+
if pct < WARN_PCT:
|
|
476
|
+
return NSColor.systemGreenColor()
|
|
477
|
+
if pct < CRIT_PCT:
|
|
478
|
+
return NSColor.systemYellowColor()
|
|
479
|
+
return NSColor.systemRedColor()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _utc_now() -> datetime:
|
|
483
|
+
"""Return current time in UTC timezone."""
|
|
484
|
+
return datetime.now(timezone.utc)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _parse_reset_time(reset_iso: Optional[str]) -> Optional[datetime]:
|
|
488
|
+
"""Parse ISO datetime string (with Z suffix) to timezone-aware datetime, or None."""
|
|
489
|
+
if not reset_iso:
|
|
490
|
+
return None
|
|
491
|
+
try:
|
|
492
|
+
dt = datetime.fromisoformat(reset_iso.replace("Z", "+00:00"))
|
|
493
|
+
if dt.tzinfo is None:
|
|
494
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
495
|
+
return dt
|
|
496
|
+
except (ValueError, AttributeError):
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def fmt_remaining(reset_iso: Optional[str]) -> str:
|
|
501
|
+
reset = _parse_reset_time(reset_iso)
|
|
502
|
+
if reset is None:
|
|
503
|
+
return UNAVAILABLE
|
|
504
|
+
secs = int((reset - _utc_now()).total_seconds())
|
|
505
|
+
if secs <= 0:
|
|
506
|
+
return "ready"
|
|
507
|
+
d, secs = divmod(secs, SECONDS_PER_DAY)
|
|
508
|
+
h, secs = divmod(secs, SECONDS_PER_HOUR)
|
|
509
|
+
m = secs // SECONDS_PER_MINUTE
|
|
510
|
+
if d:
|
|
511
|
+
return f"{d}d{h}h"
|
|
512
|
+
if h:
|
|
513
|
+
return f"{h}h{m:02d}m"
|
|
514
|
+
return f"{m}m"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def fmt_reset_clock(reset_iso: Optional[str]) -> str:
|
|
518
|
+
reset = _parse_reset_time(reset_iso)
|
|
519
|
+
if reset is None:
|
|
520
|
+
return UNAVAILABLE
|
|
521
|
+
if (reset - _utc_now()).total_seconds() <= 0:
|
|
522
|
+
return "ready"
|
|
523
|
+
return reset.astimezone().strftime("%a %H:%M")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _calc_backoff(consecutive_failures: int) -> int:
|
|
527
|
+
"""Exponential backoff capped at REFRESH_MAX_BACKOFF (default 30 minutes)."""
|
|
528
|
+
return min(2 ** min(consecutive_failures, BACKOFF_EXP_CAP), REFRESH_MAX_BACKOFF)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _fmt_pct(pct: float) -> str:
|
|
532
|
+
"""Format percentage with no decimal places (e.g., '45%')."""
|
|
533
|
+
return f"{pct:.0f}%"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _fmt_credits(used: float, cap: float, currency: str) -> str:
|
|
537
|
+
"""Format credit display (e.g., '5.00/10.00 USD')."""
|
|
538
|
+
if currency:
|
|
539
|
+
return f"{used:.2f}/{cap:.2f} {currency}"
|
|
540
|
+
return f"{used:.2f}/{cap:.2f}"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class ClaudeMonitorApp(rumps.App):
|
|
544
|
+
def __init__(self):
|
|
545
|
+
super().__init__(APP_NAME, title=f"{MENU_ICON}{APP_NAME}")
|
|
546
|
+
|
|
547
|
+
self.current_view = DEFAULT_VIEW
|
|
548
|
+
self.show_reset_time = True # toggle between reset time vs elapsed time
|
|
549
|
+
self.show_history = False # opt-in: 7-day sparkline + min/avg/max rows
|
|
550
|
+
self._latest = None # last good payload
|
|
551
|
+
self._latest_error = None # last error string
|
|
552
|
+
self._latest_at = None # datetime of last good payload
|
|
553
|
+
self._latest_plan = "" # cached plan info (don't re-read on rate limit)
|
|
554
|
+
self._is_rate_limited = False # distinguish 429 from other errors
|
|
555
|
+
self._pending = None # (payload, error, is_rate_limited) staged from worker
|
|
556
|
+
self._pending_lock = threading.Lock()
|
|
557
|
+
self._wake = threading.Event()
|
|
558
|
+
self._backoff_until = 0 # unix timestamp; worker won't poll until after this
|
|
559
|
+
self._history_lock = threading.Lock()
|
|
560
|
+
self._history = _load_history()
|
|
561
|
+
|
|
562
|
+
# Detail rows: one per known view (clickable to switch)
|
|
563
|
+
self._detail_items = {}
|
|
564
|
+
for label, key in VIEWS:
|
|
565
|
+
item = rumps.MenuItem(label, callback=self._make_switcher(key))
|
|
566
|
+
self._detail_items[key] = item
|
|
567
|
+
|
|
568
|
+
self.extra_item = rumps.MenuItem(f"{EXTRA_USAGE_LABEL}{UNAVAILABLE}")
|
|
569
|
+
self.tier_item = rumps.MenuItem(f"{PLAN_LABEL}{UNAVAILABLE}")
|
|
570
|
+
|
|
571
|
+
# Preference submenu for time display format + history toggle + autostart
|
|
572
|
+
self.pref_reset = rumps.MenuItem(PREF_LABEL_RESET_TIME, callback=self._set_show_reset_time)
|
|
573
|
+
self.pref_elapsed = rumps.MenuItem(PREF_LABEL_ELAPSED_TIME, callback=self._set_show_elapsed_time)
|
|
574
|
+
self.pref_history = rumps.MenuItem(PREF_LABEL_HISTORY, callback=self._toggle_show_history)
|
|
575
|
+
self.pref_launch = rumps.MenuItem(PREF_LABEL_LAUNCH_AT_STARTUP, callback=self._toggle_launch_at_startup)
|
|
576
|
+
self.pref_reset.state = 1 # selected by default
|
|
577
|
+
self.pref_history.state = 0 # off by default
|
|
578
|
+
self.pref_launch.state = 1 if _launchagent_installed() else 0
|
|
579
|
+
self._pref_menu = rumps.MenuItem("Preference")
|
|
580
|
+
self._pref_menu.add(self.pref_reset)
|
|
581
|
+
self._pref_menu.add(self.pref_elapsed)
|
|
582
|
+
self._pref_menu.add(rumps.separator)
|
|
583
|
+
self._pref_menu.add(self.pref_history)
|
|
584
|
+
self._pref_menu.add(self.pref_launch)
|
|
585
|
+
|
|
586
|
+
self.last_update_item = rumps.MenuItem(f"{UPDATE_LABEL}{UNAVAILABLE}")
|
|
587
|
+
self.history_item = rumps.MenuItem(HISTORY_COLLECTING)
|
|
588
|
+
self.stats_item = rumps.MenuItem(HISTORY_COLLECTING)
|
|
589
|
+
self._dashboard_item = rumps.MenuItem("Open Claude dashboard…", callback=self._open_dashboard)
|
|
590
|
+
self._refresh_item = rumps.MenuItem("Refresh now", callback=self._manual_refresh)
|
|
591
|
+
|
|
592
|
+
self.menu = self._build_menu_items()
|
|
593
|
+
|
|
594
|
+
self.title = f"{MENU_ICON}{LOADING_TEXT}"
|
|
595
|
+
|
|
596
|
+
self._worker = threading.Thread(target=self._worker_loop, daemon=True)
|
|
597
|
+
self._worker.start()
|
|
598
|
+
|
|
599
|
+
self._apply_timer = rumps.Timer(self._apply_pending, TIMER_INTERVAL)
|
|
600
|
+
self._apply_timer.start()
|
|
601
|
+
|
|
602
|
+
# ----- worker / dispatch -----
|
|
603
|
+
|
|
604
|
+
def _worker_loop(self):
|
|
605
|
+
consecutive_failures = 0
|
|
606
|
+
while True:
|
|
607
|
+
now = time.time()
|
|
608
|
+
if now < self._backoff_until:
|
|
609
|
+
self._wake.wait(timeout=min(REFRESH_SECONDS, self._backoff_until - now))
|
|
610
|
+
self._wake.clear()
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
oauth_data = _get_oauth_data()
|
|
614
|
+
payload, err, is_rate_limited = fetch_usage(oauth_data)
|
|
615
|
+
# Drop any Refresh-now clicks that arrived during the poll — the
|
|
616
|
+
# in-flight poll already satisfies them, and re-polling immediately
|
|
617
|
+
# can trip the OAuth endpoint's rate limit.
|
|
618
|
+
self._wake.clear()
|
|
619
|
+
with self._pending_lock:
|
|
620
|
+
self._pending = (payload, err, is_rate_limited, oauth_data)
|
|
621
|
+
|
|
622
|
+
if payload is not None:
|
|
623
|
+
consecutive_failures = 0
|
|
624
|
+
self._backoff_until = 0
|
|
625
|
+
self._record_snapshot(payload)
|
|
626
|
+
elif is_rate_limited:
|
|
627
|
+
consecutive_failures += 1
|
|
628
|
+
backoff = _calc_backoff(consecutive_failures)
|
|
629
|
+
self._backoff_until = time.time() + backoff
|
|
630
|
+
|
|
631
|
+
self._wake.wait(timeout=REFRESH_SECONDS)
|
|
632
|
+
self._wake.clear()
|
|
633
|
+
|
|
634
|
+
def _apply_pending(self, _sender):
|
|
635
|
+
with self._pending_lock:
|
|
636
|
+
staged = self._pending
|
|
637
|
+
self._pending = None
|
|
638
|
+
if staged is not None:
|
|
639
|
+
payload, err, is_rate_limited, oauth_data = staged
|
|
640
|
+
# Plan info comes from the keychain oauth_data, not the API
|
|
641
|
+
# response — refresh it on every poll where we have keychain
|
|
642
|
+
# data, even if the API fetch failed (e.g. 429 on first poll).
|
|
643
|
+
if oauth_data:
|
|
644
|
+
self._latest_plan = self._read_plan_info(oauth_data)
|
|
645
|
+
if payload is not None:
|
|
646
|
+
self._latest = payload
|
|
647
|
+
self._latest_error = None
|
|
648
|
+
self._is_rate_limited = False
|
|
649
|
+
self._latest_at = _utc_now()
|
|
650
|
+
else:
|
|
651
|
+
self._latest_error = err
|
|
652
|
+
self._is_rate_limited = is_rate_limited
|
|
653
|
+
# Re-render every tick so elapsed-time displays ("in 5h10m") tick down
|
|
654
|
+
# between polls instead of freezing for the full REFRESH_SECONDS interval.
|
|
655
|
+
self._render()
|
|
656
|
+
|
|
657
|
+
def _manual_refresh(self, _sender):
|
|
658
|
+
self._wake.set()
|
|
659
|
+
|
|
660
|
+
def _record_snapshot(self, payload):
|
|
661
|
+
"""Append a snapshot of the just-fetched payload, dedupe, persist atomically."""
|
|
662
|
+
s, w, ws = _extract_pcts(payload)
|
|
663
|
+
snapshot = {
|
|
664
|
+
"ts": int(time.time()),
|
|
665
|
+
"session_pct": s,
|
|
666
|
+
"weekly_pct": w,
|
|
667
|
+
"weekly_sonnet_pct": ws,
|
|
668
|
+
}
|
|
669
|
+
with self._history_lock:
|
|
670
|
+
if self._history:
|
|
671
|
+
last = self._history[-1] if isinstance(self._history[-1], dict) else {}
|
|
672
|
+
last_ts = last.get("ts")
|
|
673
|
+
if (last.get("session_pct") == s
|
|
674
|
+
and last.get("weekly_pct") == w
|
|
675
|
+
and last.get("weekly_sonnet_pct") == ws
|
|
676
|
+
and isinstance(last_ts, (int, float))
|
|
677
|
+
and snapshot["ts"] - int(last_ts) < HISTORY_DEDUPE_SECONDS):
|
|
678
|
+
return
|
|
679
|
+
self._history.append(snapshot)
|
|
680
|
+
if len(self._history) > HISTORY_MAX_ENTRIES:
|
|
681
|
+
del self._history[:len(self._history) - HISTORY_MAX_ENTRIES]
|
|
682
|
+
entries_to_save = list(self._history)
|
|
683
|
+
try:
|
|
684
|
+
_save_history_atomic(entries_to_save)
|
|
685
|
+
except OSError as e:
|
|
686
|
+
print(f"history: write failed: {e}", file=sys.stderr)
|
|
687
|
+
|
|
688
|
+
# ----- view switching -----
|
|
689
|
+
|
|
690
|
+
def _make_switcher(self, key):
|
|
691
|
+
def _cb(_sender):
|
|
692
|
+
self.current_view = key
|
|
693
|
+
self._render()
|
|
694
|
+
return _cb
|
|
695
|
+
|
|
696
|
+
def _open_dashboard(self, _sender):
|
|
697
|
+
url = os.environ.get(ENV_DASHBOARD_URL, DASHBOARD_URL)
|
|
698
|
+
try:
|
|
699
|
+
subprocess.Popen(["open", url])
|
|
700
|
+
except OSError:
|
|
701
|
+
pass
|
|
702
|
+
|
|
703
|
+
def _set_show_reset_time(self, _sender):
|
|
704
|
+
self.show_reset_time = True
|
|
705
|
+
self._update_pref_states(True)
|
|
706
|
+
self._render()
|
|
707
|
+
|
|
708
|
+
def _set_show_elapsed_time(self, _sender):
|
|
709
|
+
self.show_reset_time = False
|
|
710
|
+
self._update_pref_states(False)
|
|
711
|
+
self._render()
|
|
712
|
+
|
|
713
|
+
def _update_pref_states(self, reset_selected: bool):
|
|
714
|
+
"""Update checkbox states for reset time / elapsed time preferences."""
|
|
715
|
+
self.pref_reset.state = 1 if reset_selected else 0
|
|
716
|
+
self.pref_elapsed.state = 0 if reset_selected else 1
|
|
717
|
+
|
|
718
|
+
def _build_menu_items(self):
|
|
719
|
+
"""Build the menu list, conditionally including the 7-day history rows."""
|
|
720
|
+
items = [
|
|
721
|
+
self._detail_items["five_hour"],
|
|
722
|
+
self._detail_items["seven_day"],
|
|
723
|
+
self._detail_items["seven_day_sonnet"],
|
|
724
|
+
None,
|
|
725
|
+
]
|
|
726
|
+
if self.show_history:
|
|
727
|
+
items.extend([self.history_item, self.stats_item, None])
|
|
728
|
+
items.extend([
|
|
729
|
+
self.tier_item,
|
|
730
|
+
self.extra_item,
|
|
731
|
+
None,
|
|
732
|
+
self._pref_menu,
|
|
733
|
+
self._dashboard_item,
|
|
734
|
+
None,
|
|
735
|
+
self.last_update_item,
|
|
736
|
+
self._refresh_item,
|
|
737
|
+
])
|
|
738
|
+
return items
|
|
739
|
+
|
|
740
|
+
def _toggle_show_history(self, _sender):
|
|
741
|
+
self.show_history = not self.show_history
|
|
742
|
+
self.pref_history.state = 1 if self.show_history else 0
|
|
743
|
+
self.menu.clear()
|
|
744
|
+
for item in self._build_menu_items():
|
|
745
|
+
self.menu.add(rumps.separator if item is None else item)
|
|
746
|
+
self._render()
|
|
747
|
+
|
|
748
|
+
def _toggle_launch_at_startup(self, _sender):
|
|
749
|
+
if _launchagent_installed():
|
|
750
|
+
_uninstall_launchagent()
|
|
751
|
+
msg = "tokenmaxxing will no longer start automatically on login."
|
|
752
|
+
else:
|
|
753
|
+
_install_launchagent()
|
|
754
|
+
msg = "tokenmaxxing will start automatically on next login."
|
|
755
|
+
self.pref_launch.state = 1 if _launchagent_installed() else 0
|
|
756
|
+
# Notification is best-effort: fails silently in unbundled apps
|
|
757
|
+
try:
|
|
758
|
+
rumps.notification("tokenmaxxing", "Launch at login", msg)
|
|
759
|
+
except Exception: # noqa: BLE001
|
|
760
|
+
pass
|
|
761
|
+
|
|
762
|
+
# ----- rendering -----
|
|
763
|
+
|
|
764
|
+
def _get_time_value(self, resets_at: Optional[str]) -> str:
|
|
765
|
+
"""Format time display based on preference: reset time or remaining time."""
|
|
766
|
+
if self.show_reset_time:
|
|
767
|
+
return fmt_reset_clock(resets_at)
|
|
768
|
+
return fmt_remaining(resets_at)
|
|
769
|
+
|
|
770
|
+
def _fmt_time_display(self, resets_at: Optional[str]) -> str:
|
|
771
|
+
"""Format time display with prefix: 'resets at HH:MM' or 'in Xh Xm'."""
|
|
772
|
+
time_value = self._get_time_value(resets_at)
|
|
773
|
+
if time_value in (UNAVAILABLE, "ready"):
|
|
774
|
+
return time_value
|
|
775
|
+
if self.show_reset_time:
|
|
776
|
+
return f"{TIME_DISPLAY_RESETS}{time_value}"
|
|
777
|
+
return f"{TIME_DISPLAY_IN}{time_value}"
|
|
778
|
+
|
|
779
|
+
def _fmt_update_time(self) -> str:
|
|
780
|
+
"""Format the last update timestamp or unavailable marker."""
|
|
781
|
+
if self._latest_at:
|
|
782
|
+
return self._latest_at.astimezone().strftime(UPDATE_TIME_FORMAT)
|
|
783
|
+
return UNAVAILABLE
|
|
784
|
+
|
|
785
|
+
def _fmt_extra_usage(self, extra: Optional[dict]) -> str:
|
|
786
|
+
"""Format extra usage menu item from payload dict."""
|
|
787
|
+
extra = extra if isinstance(extra, dict) else {}
|
|
788
|
+
if not extra.get("is_enabled"):
|
|
789
|
+
return f"{EXTRA_USAGE_LABEL}{EXTRA_USAGE_OFF}"
|
|
790
|
+
parts = []
|
|
791
|
+
util = extra.get("utilization")
|
|
792
|
+
if util is not None:
|
|
793
|
+
parts.append(_fmt_pct(util))
|
|
794
|
+
used = extra.get("used_credits")
|
|
795
|
+
cap = extra.get("monthly_limit")
|
|
796
|
+
if used is not None and cap is not None:
|
|
797
|
+
currency = extra.get("currency") or ""
|
|
798
|
+
parts.append(_fmt_credits(used, cap, currency))
|
|
799
|
+
return EXTRA_USAGE_LABEL + (SEPARATOR.join(parts) or EXTRA_USAGE_ENABLED)
|
|
800
|
+
|
|
801
|
+
def _get_status_suffix(self) -> str:
|
|
802
|
+
"""Return status suffix: ' [rate limited]', ' (stale)', or ''."""
|
|
803
|
+
if self._is_rate_limited:
|
|
804
|
+
return STATUS_RATE_LIMITED
|
|
805
|
+
if self._latest_error:
|
|
806
|
+
return STATUS_STALE
|
|
807
|
+
return ""
|
|
808
|
+
|
|
809
|
+
def _view_is_available(self, view) -> bool:
|
|
810
|
+
"""Check if a view dict has valid utilization data."""
|
|
811
|
+
return isinstance(view, dict) and view.get("utilization") is not None
|
|
812
|
+
|
|
813
|
+
def _status_button(self):
|
|
814
|
+
"""Reach the underlying NSStatusBarButton through rumps' wrapper."""
|
|
815
|
+
try:
|
|
816
|
+
return self._nsapp.nsstatusitem.button()
|
|
817
|
+
except Exception: # noqa: BLE001
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
def _set_title(self, prefix: str, bar_text: str, tail: str, pct: Optional[float]):
|
|
821
|
+
"""Set the menu-bar title with the bar segment tinted by `pct`.
|
|
822
|
+
|
|
823
|
+
Falls back to plain title if AppKit / the status button isn't ready
|
|
824
|
+
yet (e.g. before `run()` finishes wiring up).
|
|
825
|
+
"""
|
|
826
|
+
full = f"{prefix}{bar_text}{tail}"
|
|
827
|
+
if _HAS_APPKIT:
|
|
828
|
+
button = self._status_button()
|
|
829
|
+
if button is not None:
|
|
830
|
+
try:
|
|
831
|
+
attr = NSMutableAttributedString.alloc().initWithString_(full)
|
|
832
|
+
color = status_color(pct)
|
|
833
|
+
filled_len = bar_text.count("█")
|
|
834
|
+
if color is not None and filled_len > 0:
|
|
835
|
+
attr.addAttribute_value_range_(
|
|
836
|
+
NSForegroundColorAttributeName,
|
|
837
|
+
color,
|
|
838
|
+
(len(prefix), filled_len),
|
|
839
|
+
)
|
|
840
|
+
# The empty `░` segment inherits the default label color
|
|
841
|
+
# (white on dark menu bar, black on light) — no override.
|
|
842
|
+
button.setAttributedTitle_(attr)
|
|
843
|
+
return
|
|
844
|
+
except Exception: # noqa: BLE001
|
|
845
|
+
pass
|
|
846
|
+
self.title = full
|
|
847
|
+
|
|
848
|
+
def _render_history(self):
|
|
849
|
+
"""Refresh the sparkline+trend row and the min/avg/max row from in-memory history."""
|
|
850
|
+
with self._history_lock:
|
|
851
|
+
snapshots = list(self._history)
|
|
852
|
+
key = HISTORY_KEY_BY_VIEW.get(self.current_view, "session_pct")
|
|
853
|
+
now_ts = int(time.time())
|
|
854
|
+
spark = _render_sparkline(snapshots, key, now_ts) if snapshots else ""
|
|
855
|
+
if spark:
|
|
856
|
+
trend = _compute_trend(snapshots, key, now_ts)
|
|
857
|
+
self.history_item.title = (
|
|
858
|
+
f"{HISTORY_LABEL}{spark} {trend}".rstrip() if trend
|
|
859
|
+
else f"{HISTORY_LABEL}{spark}"
|
|
860
|
+
)
|
|
861
|
+
else:
|
|
862
|
+
self.history_item.title = HISTORY_COLLECTING
|
|
863
|
+
mn, avg, mx = _compute_min_max_avg(snapshots, key, now_ts)
|
|
864
|
+
if mn is None:
|
|
865
|
+
# Differentiate from history_item's "7d: collecting…" so the two
|
|
866
|
+
# empty-state rows don't render identically next to each other.
|
|
867
|
+
self.stats_item.title = (
|
|
868
|
+
f"{HISTORY_LABEL}min {UNAVAILABLE}{SEPARATOR}"
|
|
869
|
+
f"avg {UNAVAILABLE}{SEPARATOR}max {UNAVAILABLE}"
|
|
870
|
+
)
|
|
871
|
+
else:
|
|
872
|
+
self.stats_item.title = (
|
|
873
|
+
f"{HISTORY_LABEL}min {_fmt_pct(mn)}{SEPARATOR}"
|
|
874
|
+
f"avg {_fmt_pct(avg)}{SEPARATOR}max {_fmt_pct(mx)}"
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
def _render(self):
|
|
878
|
+
payload = self._latest
|
|
879
|
+
err = self._latest_error
|
|
880
|
+
status_suffix = self._get_status_suffix()
|
|
881
|
+
if self.show_history:
|
|
882
|
+
self._render_history()
|
|
883
|
+
|
|
884
|
+
# No cached data yet: loading state (err is None) or error state (err is not None).
|
|
885
|
+
# Either way, render markers/emoji on detail items so the user can see which view
|
|
886
|
+
# is being tracked even before the first poll lands.
|
|
887
|
+
if payload is None:
|
|
888
|
+
if err is not None:
|
|
889
|
+
self._set_title(MENU_ICON, "", err, None)
|
|
890
|
+
for key, item in self._detail_items.items():
|
|
891
|
+
marker = MARKER_SELECTED if key == self.current_view else MARKER_UNSELECTED
|
|
892
|
+
item.title = f"{marker}{status_emoji(None)} {VIEW_LABEL[key]}: {UNAVAILABLE}"
|
|
893
|
+
self.extra_item.title = f"{EXTRA_USAGE_LABEL}{UNAVAILABLE}"
|
|
894
|
+
self.tier_item.title = f"{PLAN_LABEL}{self._latest_plan or UNAVAILABLE}"
|
|
895
|
+
self.last_update_item.title = f"{UPDATE_LABEL}{NEVER_UPDATED}"
|
|
896
|
+
return
|
|
897
|
+
|
|
898
|
+
# menu-bar title from current_view
|
|
899
|
+
view = payload.get(self.current_view)
|
|
900
|
+
prefix = VIEW_PREFIX.get(self.current_view, "")
|
|
901
|
+
|
|
902
|
+
if self._view_is_available(view):
|
|
903
|
+
pct = float(view["utilization"])
|
|
904
|
+
time_display = self._get_time_value(view.get("resets_at"))
|
|
905
|
+
bar_text = bar(pct)
|
|
906
|
+
tail = f" {_fmt_pct(pct)}{SEPARATOR}{time_display}{status_suffix}"
|
|
907
|
+
self._set_title(prefix, bar_text, tail, pct)
|
|
908
|
+
else:
|
|
909
|
+
self._set_title(prefix, bar(None), f" {VIEW_UNAVAILABLE}{status_suffix}", None)
|
|
910
|
+
|
|
911
|
+
# Detail rows for every known view
|
|
912
|
+
for key, item in self._detail_items.items():
|
|
913
|
+
v = payload.get(key)
|
|
914
|
+
label = VIEW_LABEL[key]
|
|
915
|
+
marker = MARKER_SELECTED if key == self.current_view else MARKER_UNSELECTED
|
|
916
|
+
if not self._view_is_available(v):
|
|
917
|
+
item.title = f"{marker}{status_emoji(None)} {label}: {VIEW_UNAVAILABLE}{status_suffix}"
|
|
918
|
+
continue
|
|
919
|
+
pct = v.get("utilization")
|
|
920
|
+
resets = v.get("resets_at")
|
|
921
|
+
emoji = status_emoji(pct)
|
|
922
|
+
time_info = self._fmt_time_display(resets)
|
|
923
|
+
item.title = (
|
|
924
|
+
f"{marker}{emoji} {label}{DETAIL_ITEM_SPACER}{bar(pct)} {_fmt_pct(pct)}{DETAIL_ITEM_SPACER}"
|
|
925
|
+
f"{time_info}{status_suffix}"
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
self.extra_item.title = self._fmt_extra_usage(payload.get("extra_usage")) + status_suffix
|
|
929
|
+
|
|
930
|
+
# Plan info — use cached value (only updated on successful fetch)
|
|
931
|
+
plan_text = self._latest_plan or UNAVAILABLE
|
|
932
|
+
self.tier_item.title = f"{PLAN_LABEL}{plan_text}{status_suffix}"
|
|
933
|
+
|
|
934
|
+
self.last_update_item.title = f"{UPDATE_LABEL}{self._fmt_update_time()}{status_suffix}"
|
|
935
|
+
|
|
936
|
+
def _read_plan_info(self, oauth_data: Optional[dict] = None) -> str:
|
|
937
|
+
"""Format plan and tier info from OAuth data. Returns empty string if unavailable."""
|
|
938
|
+
if not oauth_data:
|
|
939
|
+
return ""
|
|
940
|
+
|
|
941
|
+
sub = oauth_data.get("subscriptionType") or ""
|
|
942
|
+
tier = oauth_data.get("rateLimitTier") or ""
|
|
943
|
+
|
|
944
|
+
sub_name = SUB_TYPE_MAP.get(sub, sub)
|
|
945
|
+
tier_name = RATE_LIMIT_TIER_MAP.get(tier, tier)
|
|
946
|
+
|
|
947
|
+
return SEPARATOR.join(p for p in (sub_name, tier_name) if p)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def main():
|
|
951
|
+
"""Entry point for the `tokenmaxxing` console script."""
|
|
952
|
+
ClaudeMonitorApp().run()
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
if __name__ == "__main__":
|
|
956
|
+
main()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tokenmaxxing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Menu bar app showing your live Claude Code session and weekly usage as a colored progress bar.
|
|
5
|
+
Project-URL: Homepage, https://github.com/alvations/tokenmaxxing
|
|
6
|
+
Project-URL: Repository, https://github.com/alvations/tokenmaxxing
|
|
7
|
+
Project-URL: Issues, https://github.com/alvations/tokenmaxxing/issues
|
|
8
|
+
Author-email: alvations <alvations@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anthropic,claude,claude-code,menu-bar,rate-limit,usage
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Desktop Environment
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: rumps>=0.4.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# tokenmaxxing
|
|
28
|
+
|
|
29
|
+
A menu bar app that shows your live Claude Code session and weekly usage as a colored progress bar — sitting next to the system clock so you always know how close you are to a rate-limit reset.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
Session ███░░░░░ 38% · resets at Fri 07:00
|
|
33
|
+
Weekly ███████░ 87% · resets at Fri 09:00
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The bar is tinted by utilization:
|
|
37
|
+
|
|
38
|
+
| color | range | meaning |
|
|
39
|
+
| ----- | ------ | ----------------------------------- |
|
|
40
|
+
| ⚪ | n/a | no active window / data unavailable |
|
|
41
|
+
| 🟢 | <50% | plenty of headroom |
|
|
42
|
+
| 🟡 | 50–79% | getting close |
|
|
43
|
+
| 🔴 | ≥80% | reaching the limit very soon |
|
|
44
|
+
|
|
45
|
+
Only the filled `█` segment is colored; the empty `░` segment stays in the menu bar's default text color (white on dark, dark on light).
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Requires **Python 3.9+**.
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
pip install tokenmaxxing
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This pulls in [`rumps`](https://github.com/jaredks/rumps) (which in turn pulls in PyObjC).
|
|
56
|
+
|
|
57
|
+
## Run
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
tokenmaxxing
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or as a module:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
python -m tokenmaxxing
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Detached (so the menu bar app survives the launching shell):
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
nohup tokenmaxxing >/dev/null 2>&1 </dev/null &!
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Autostart
|
|
76
|
+
|
|
77
|
+
Pick **one** of the two options below. Don't enable both — you'll get two instances of the app.
|
|
78
|
+
|
|
79
|
+
### Option 1 — Launch at login
|
|
80
|
+
|
|
81
|
+
Open the menu bar dropdown → **Preference** → check **Launch at login**.
|
|
82
|
+
|
|
83
|
+
This writes a `~/Library/LaunchAgents/com.tokenmaxxing.app.plist` and a tiny wrapper script at `~/.tokenmaxxing/tokenmaxxing` (so System Settings → Login Items shows the friendly name, not "python3.9"). The system's `launchd` reads the plist on every login and starts the app. Uncheck the menu item to remove both files.
|
|
84
|
+
|
|
85
|
+
> **Heads up — Gatekeeper warning.** Your system will show "tokenmaxxing is an item from an unidentified developer" the first time it's added to login items. This is unavoidable for any unsigned tool (code signing requires a paid Apple Developer account). To allow it: **System Settings → Privacy & Security → scroll to the bottom → click "Allow Anyway"**. Subsequent boots skip the prompt. If that friction bothers you, use Option 2 instead — launching from a shell is treated as user-initiated and never triggers Gatekeeper.
|
|
86
|
+
|
|
87
|
+
### Option 2 — Launch on first interactive shell (terminal-driven)
|
|
88
|
+
|
|
89
|
+
Append this guarded launcher to `~/.zshrc`:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
# tokenmaxxing — menu bar app showing Claude Code session/weekly usage
|
|
93
|
+
if [[ -o interactive ]] && ! pgrep -f "tokenmaxxing" >/dev/null 2>&1; then
|
|
94
|
+
nohup tokenmaxxing >/dev/null 2>&1 </dev/null &!
|
|
95
|
+
fi
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The `pgrep` guard keeps it singleton across nested shells. The `&!` (zsh) detaches and disowns. The first interactive shell of the day starts it; subsequent shells skip via the guard.
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
It polls Anthropic's OAuth usage endpoint — the same call Claude Code makes internally:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
GET https://api.anthropic.com/api/oauth/usage
|
|
106
|
+
Authorization: Bearer <token>
|
|
107
|
+
anthropic-beta: oauth-2025-04-20
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The OAuth access token is read from your system keychain (service `Claude Code-credentials`) with `~/.claude/.credentials.json` as fallback. Polling consumes **zero tokens** — it returns metadata only:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"five_hour": {"utilization": 38, "resets_at": "2026-05-01T11:00:00Z"},
|
|
115
|
+
"seven_day": {"utilization": 87, "resets_at": "2026-05-01T13:00:00Z"},
|
|
116
|
+
"seven_day_opus": null,
|
|
117
|
+
"seven_day_sonnet": {"utilization": 100, "resets_at": "2026-05-01T13:00:00Z"},
|
|
118
|
+
"extra_usage": {"is_enabled": false}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Refresh runs on a background thread every 5 minutes (the OAuth endpoint has aggressive undocumented rate limits, so polling more frequently triggers `429`s). Token refresh is handled by Claude Code itself — when `claude` runs, it rotates the keychain entry and the next poll picks it up automatically.
|
|
123
|
+
|
|
124
|
+
## Dropdown menu
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
✓ 🟢 Session (5h) ███░░░░░ 38% resets at Fri 07:00
|
|
128
|
+
🔴 Weekly (7d) ███████░ 87% resets at Fri 09:00
|
|
129
|
+
🔴 Weekly Sonnet ████████ 100% resets at Fri 09:00
|
|
130
|
+
─────────────
|
|
131
|
+
Plan: Claude Max · Tier 2
|
|
132
|
+
Extra usage: off
|
|
133
|
+
─────────────
|
|
134
|
+
Preference ▸ ← reset-time vs elapsed-time, opt-in 7-day history
|
|
135
|
+
Open Claude dashboard…
|
|
136
|
+
─────────────
|
|
137
|
+
Updated: 14:32:01
|
|
138
|
+
Refresh now
|
|
139
|
+
Quit
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Click any view row to switch which window the menu-bar bar tracks. The `✓` marker shows the active view.
|
|
143
|
+
|
|
144
|
+
## Preferences
|
|
145
|
+
|
|
146
|
+
Open the **Preference** submenu:
|
|
147
|
+
|
|
148
|
+
- **Show "resets at Fri 07:00"** / **Show "resets in 4h10m"** — radio toggle for time format
|
|
149
|
+
- **Show 7-day history** — opt-in checkbox; when on, two extra rows appear in the dropdown:
|
|
150
|
+
- `7d: ▁▂▃▄▅▆▇█ ↑` — sparkline of utilization over the last 7 days, plus a trend arrow comparing the last 24h vs the prior 24h (±3pp threshold)
|
|
151
|
+
- `7d: min 12% · avg 47% · max 89%` — min / avg / max for the same window
|
|
152
|
+
- **Launch at login** — opt-in checkbox; installs/removes a LaunchAgent plist at `~/Library/LaunchAgents/com.tokenmaxxing.app.plist`. Takes effect on next login.
|
|
153
|
+
|
|
154
|
+
The history is recorded continuously to `~/.tokenmaxxing/history.json` (one snapshot per successful poll, capped at 2000 rolling entries) regardless of whether you display it — so when you turn it on, data is already there.
|
|
155
|
+
|
|
156
|
+
When the most recent poll has failed (network blip, transient error) but a previous successful poll's data is still cached, every row shows a `(stale)` suffix. When the OAuth endpoint is rate-limiting, you see `[rate limited]` instead.
|
|
157
|
+
|
|
158
|
+
## Configuration (env vars)
|
|
159
|
+
|
|
160
|
+
- `CLAUDE_DASHBOARD_URL` — URL for the "Open Claude dashboard…" item (default `https://claude.ai/settings/usage`)
|
|
161
|
+
|
|
162
|
+
## Implementation notes
|
|
163
|
+
|
|
164
|
+
- **Why not parse `~/.claude/projects/*.jsonl`?** Tried first, scrapped. Even with `os.scandir` + per-file mtime cache, parsing was lossy (no auth signal, no plan-aware caps) and stale (only sees what Claude Code chose to log). The OAuth endpoint is authoritative and free.
|
|
165
|
+
- **Why two threads?** AppKit calls (setting menu/title) must happen on the main thread; HTTP calls shouldn't block it. A daemon worker fetches usage every 5 min and stages the result; a 1s `rumps.Timer` on the main thread applies whatever's staged so countdowns tick smoothly.
|
|
166
|
+
- **Coloring:** `NSMutableAttributedString` with `NSForegroundColorAttributeName` applied only to the leading `█` chars. `NSColor.system{Green,Yellow,Red}Color` adapt to dark/light mode. The empty `░` segment is left untouched so it inherits the default `labelColor`.
|
|
167
|
+
- **Backoff:** consecutive 429s back off exponentially up to 30 minutes between polls before retrying.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
Apache-2.0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
tokenmaxxing/__init__.py,sha256=MjbnSULsYGhfRHrncNj81PfQi7n7K4h1dygD3iSCH9s,171
|
|
2
|
+
tokenmaxxing/__main__.py,sha256=FYsLMI6trGBYzcOXIMU1FWTtg2UeqC1EykaSJF8xv9g,140
|
|
3
|
+
tokenmaxxing/app.py,sha256=cCQde-DqkAwDMaSk7s8k6I_xTlwC6QUNJEtSkSlL87o,35325
|
|
4
|
+
tokenmaxxing-0.1.0.dist-info/METADATA,sha256=vBT4yymqbW7YH3eAZAhr1QuJ7UemVaDkw9taT4rcaNA,7970
|
|
5
|
+
tokenmaxxing-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
+
tokenmaxxing-0.1.0.dist-info/entry_points.txt,sha256=GIYXbLZcBSlPmXKzWfTOgHQDAwl0t1h4ORFnYdt_inM,55
|
|
7
|
+
tokenmaxxing-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
8
|
+
tokenmaxxing-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|