tokenwatch 0.2.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.
- tokenwatch/__init__.py +2 -0
- tokenwatch/alerts.py +142 -0
- tokenwatch/cli.py +170 -0
- tokenwatch/config.py +50 -0
- tokenwatch/license_check.py +126 -0
- tokenwatch/main.py +263 -0
- tokenwatch/parser.py +291 -0
- tokenwatch/pricing.py +198 -0
- tokenwatch/seed_demo.py +148 -0
- tokenwatch/static/index.html +604 -0
- tokenwatch/test_parser.py +235 -0
- tokenwatch/updater.py +54 -0
- tokenwatch/watcher.py +56 -0
- tokenwatch-0.2.0.dist-info/METADATA +124 -0
- tokenwatch-0.2.0.dist-info/RECORD +19 -0
- tokenwatch-0.2.0.dist-info/WHEEL +5 -0
- tokenwatch-0.2.0.dist-info/entry_points.txt +2 -0
- tokenwatch-0.2.0.dist-info/top_level.txt +2 -0
- tokenwatch-backend/main.py +317 -0
tokenwatch/__init__.py
ADDED
tokenwatch/alerts.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alerts.py — Desktop notifications + weekly email digest.
|
|
3
|
+
|
|
4
|
+
Notifications work on:
|
|
5
|
+
macOS — osascript
|
|
6
|
+
Linux — notify-send (libnotify)
|
|
7
|
+
Windows — win10toast or plyer fallback
|
|
8
|
+
|
|
9
|
+
Email digest uses Resend (free tier: 3000 emails/mo).
|
|
10
|
+
Set RESEND_API_KEY env var + digest_email in config to enable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import subprocess
|
|
17
|
+
from datetime import date, timedelta
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Desktop notifications ─────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def notify(title: str, message: str) -> None:
|
|
25
|
+
"""Send a desktop notification. Silently fails if not supported."""
|
|
26
|
+
system = platform.system()
|
|
27
|
+
try:
|
|
28
|
+
if system == "Darwin":
|
|
29
|
+
script = f'display notification "{message}" with title "{title}" sound name "Tink"'
|
|
30
|
+
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=3)
|
|
31
|
+
|
|
32
|
+
elif system == "Linux":
|
|
33
|
+
subprocess.run(
|
|
34
|
+
["notify-send", title, message, "--app-name=TokenWatch", "--icon=dialog-information"],
|
|
35
|
+
capture_output=True, timeout=3,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
elif system == "Windows":
|
|
39
|
+
try:
|
|
40
|
+
from win10toast import ToastNotifier
|
|
41
|
+
ToastNotifier().show_toast(title, message, duration=5, threaded=True)
|
|
42
|
+
except ImportError:
|
|
43
|
+
try:
|
|
44
|
+
import plyer
|
|
45
|
+
plyer.notification.notify(title=title, message=message, app_name="TokenWatch")
|
|
46
|
+
except ImportError:
|
|
47
|
+
logger.debug("No Windows notification library available (win10toast / plyer).")
|
|
48
|
+
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.debug(f"Notification failed: {e}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Budget alert state ────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
_alert_sent: set[str] = set()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_budget(cost: float, budget: float, thresholds: list[int]) -> None:
|
|
59
|
+
"""Fire a notification when cost crosses a budget threshold."""
|
|
60
|
+
today = str(date.today())
|
|
61
|
+
for pct in thresholds:
|
|
62
|
+
key = f"{today}-{pct}"
|
|
63
|
+
if cost >= budget * (pct / 100) and key not in _alert_sent:
|
|
64
|
+
_alert_sent.add(key)
|
|
65
|
+
msg = f"${cost:.2f} spent today — {pct}% of your ${budget:.0f} budget"
|
|
66
|
+
notify("TokenWatch Budget Alert", msg)
|
|
67
|
+
logger.info(f"Budget alert sent: {pct}%")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reset_alerts() -> None:
|
|
71
|
+
"""Call at midnight to allow today's alerts to fire again tomorrow."""
|
|
72
|
+
_alert_sent.clear()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Weekly email digest ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def send_weekly_digest(to_email: str, sessions_data: list[dict]) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Send a weekly cost summary email via Resend.
|
|
80
|
+
Requires RESEND_API_KEY environment variable.
|
|
81
|
+
Returns True on success.
|
|
82
|
+
"""
|
|
83
|
+
api_key = os.environ.get("RESEND_API_KEY", "")
|
|
84
|
+
if not api_key or not to_email:
|
|
85
|
+
logger.debug("Digest skipped: no RESEND_API_KEY or no email configured.")
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
import urllib.request, json as _json
|
|
90
|
+
|
|
91
|
+
total = sum(s.get("total_cost", 0) for s in sessions_data)
|
|
92
|
+
week_ago = str(date.today() - timedelta(days=7))
|
|
93
|
+
top_projects: dict[str, float] = {}
|
|
94
|
+
for s in sessions_data:
|
|
95
|
+
p = s.get("project", "unknown")
|
|
96
|
+
top_projects[p] = round(top_projects.get(p, 0) + s.get("total_cost", 0), 4)
|
|
97
|
+
|
|
98
|
+
top = sorted(top_projects.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
99
|
+
rows = "".join(
|
|
100
|
+
f"<tr><td style='padding:6px 12px;border-bottom:1px solid #eee'>{p}</td>"
|
|
101
|
+
f"<td style='padding:6px 12px;border-bottom:1px solid #eee;text-align:right'>${c:.4f}</td></tr>"
|
|
102
|
+
for p, c in top
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
html = f"""
|
|
106
|
+
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;color:#1a1a18">
|
|
107
|
+
<h2 style="font-size:18px;font-weight:500;margin-bottom:4px">Your Claude Code spend last week</h2>
|
|
108
|
+
<p style="color:#6b6a64;margin-bottom:24px;font-size:13px">{week_ago} → {date.today()}</p>
|
|
109
|
+
<div style="background:#f8f7f4;border-radius:12px;padding:20px;margin-bottom:20px;text-align:center">
|
|
110
|
+
<p style="font-size:36px;font-weight:500;margin:0">${total:.2f}</p>
|
|
111
|
+
<p style="color:#6b6a64;font-size:13px;margin:4px 0 0">{len(sessions_data)} sessions</p>
|
|
112
|
+
</div>
|
|
113
|
+
<p style="font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;color:#6b6a64;margin-bottom:8px">By project</p>
|
|
114
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px">{rows}</table>
|
|
115
|
+
<p style="margin-top:24px;font-size:12px;color:#6b6a64">
|
|
116
|
+
TokenWatch · <a href="http://localhost:7842" style="color:#178BCA">Open dashboard</a>
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
payload = _json.dumps({
|
|
122
|
+
"from": "TokenWatch <digest@tokenwatch.dev>",
|
|
123
|
+
"to": [to_email],
|
|
124
|
+
"subject": f"Your Claude Code bill last week: ${total:.2f}",
|
|
125
|
+
"html": html,
|
|
126
|
+
}).encode()
|
|
127
|
+
|
|
128
|
+
req = urllib.request.Request(
|
|
129
|
+
"https://api.resend.com/emails",
|
|
130
|
+
data=payload,
|
|
131
|
+
headers={
|
|
132
|
+
"Authorization": f"Bearer {api_key}",
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
137
|
+
logger.info(f"Digest sent to {to_email} (status {resp.status})")
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.warning(f"Digest send failed: {e}")
|
|
142
|
+
return False
|
tokenwatch/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — `tokenwatch` command.
|
|
3
|
+
|
|
4
|
+
tokenwatch start dashboard
|
|
5
|
+
tokenwatch start start dashboard
|
|
6
|
+
tokenwatch status print today's cost
|
|
7
|
+
tokenwatch export export to CSV/JSON
|
|
8
|
+
tokenwatch seed generate demo data
|
|
9
|
+
tokenwatch seed --clean remove demo data
|
|
10
|
+
tokenwatch config show config
|
|
11
|
+
tokenwatch license activate Pro license
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import webbrowser
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_start(args) -> None:
|
|
21
|
+
from tokenwatch import config as cfg_module
|
|
22
|
+
import uvicorn
|
|
23
|
+
|
|
24
|
+
cfg = cfg_module.load()
|
|
25
|
+
if args.budget is not None:
|
|
26
|
+
cfg["daily_budget"] = args.budget
|
|
27
|
+
cfg_module.save(cfg)
|
|
28
|
+
if args.port:
|
|
29
|
+
cfg["port"] = args.port
|
|
30
|
+
cfg_module.save(cfg)
|
|
31
|
+
|
|
32
|
+
port = cfg["port"]
|
|
33
|
+
open_browser = cfg["open_browser"] and not args.no_browser
|
|
34
|
+
url = f"http://localhost:{port}"
|
|
35
|
+
|
|
36
|
+
from tokenwatch import __version__
|
|
37
|
+
print(f"\n ● TokenWatch v{__version__}")
|
|
38
|
+
print(f" Dashboard → {url}")
|
|
39
|
+
print(f" Budget → ${cfg['daily_budget']:.2f}/day")
|
|
40
|
+
print(f" Press Ctrl+C to stop\n")
|
|
41
|
+
|
|
42
|
+
if open_browser:
|
|
43
|
+
threading.Timer(1.4, lambda: webbrowser.open(url)).start()
|
|
44
|
+
|
|
45
|
+
uvicorn.run("tokenwatch.main:app", host="127.0.0.1", port=port, log_level="warning")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_status(args) -> None:
|
|
49
|
+
from tokenwatch.parser import get_daily_summary, get_today_cost
|
|
50
|
+
from tokenwatch import config as cfg_module
|
|
51
|
+
|
|
52
|
+
cfg = cfg_module.load()
|
|
53
|
+
budget = cfg.get("daily_budget", 10.0)
|
|
54
|
+
cost = get_today_cost()
|
|
55
|
+
pct = cost / budget * 100 if budget else 0
|
|
56
|
+
summary = get_daily_summary(days=7)
|
|
57
|
+
max_cost = max((d["cost"] for d in summary), default=1) or 1
|
|
58
|
+
|
|
59
|
+
print(f"\n TokenWatch — today")
|
|
60
|
+
print(f" Spent : ${cost:.4f} ({pct:.1f}% of ${budget:.0f} budget)")
|
|
61
|
+
if summary:
|
|
62
|
+
print(f"\n Last 7 days:")
|
|
63
|
+
for d in summary[-7:]:
|
|
64
|
+
bar = "█" * int(d["cost"] / max_cost * 20)
|
|
65
|
+
print(f" {d['date']} {bar:<20} ${d['cost']:.4f}")
|
|
66
|
+
print()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_export(args) -> None:
|
|
70
|
+
from tokenwatch.parser import export_csv, get_all_sessions
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
import json
|
|
73
|
+
|
|
74
|
+
if args.format == "csv":
|
|
75
|
+
out = export_csv(days=args.days)
|
|
76
|
+
fname = "tokenwatch-export.csv"
|
|
77
|
+
Path(fname).write_text(out)
|
|
78
|
+
print(f"Exported to {fname}")
|
|
79
|
+
else:
|
|
80
|
+
data = [s.to_dict() for s in get_all_sessions(days=args.days)]
|
|
81
|
+
fname = "tokenwatch-export.json"
|
|
82
|
+
Path(fname).write_text(json.dumps(data, indent=2))
|
|
83
|
+
print(f"Exported {len(data)} sessions to {fname}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_seed(args) -> None:
|
|
87
|
+
from tokenwatch import seed_demo
|
|
88
|
+
if args.clean:
|
|
89
|
+
seed_demo.clean()
|
|
90
|
+
else:
|
|
91
|
+
seed_demo.generate(days=args.days)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_config(args) -> None:
|
|
95
|
+
from tokenwatch import config as cfg_module
|
|
96
|
+
cfg = cfg_module.load()
|
|
97
|
+
print(f"\n Config → {cfg_module.CONFIG_FILE}\n")
|
|
98
|
+
for k, v in cfg.items():
|
|
99
|
+
if k == "license_key" and v:
|
|
100
|
+
v = v[:4] + "…" + v[-4:] if len(v) >= 8 else "set"
|
|
101
|
+
print(f" {k:<22} {v}")
|
|
102
|
+
print()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_license(args) -> None:
|
|
106
|
+
from tokenwatch.license_check import validate
|
|
107
|
+
from tokenwatch import config as cfg_module
|
|
108
|
+
|
|
109
|
+
key = args.key or input("Enter your license key: ").strip()
|
|
110
|
+
if not key:
|
|
111
|
+
print("No key entered.")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
print("Validating…")
|
|
115
|
+
result = validate(key)
|
|
116
|
+
|
|
117
|
+
if result["valid"]:
|
|
118
|
+
cfg = cfg_module.load()
|
|
119
|
+
cfg["license_key"] = key
|
|
120
|
+
cfg_module.save(cfg)
|
|
121
|
+
print(f"✓ License activated ({result['plan']} plan)")
|
|
122
|
+
else:
|
|
123
|
+
print(f"✗ Invalid license: {result.get('message', '')}")
|
|
124
|
+
print(" Get a license at https://tokenwatch.dev")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
parser = argparse.ArgumentParser(prog="tokenwatch", description="Real-time Claude Code cost dashboard")
|
|
129
|
+
sub = parser.add_subparsers(dest="command")
|
|
130
|
+
|
|
131
|
+
p_start = sub.add_parser("start")
|
|
132
|
+
p_start.add_argument("--port", type=int, default=None)
|
|
133
|
+
p_start.add_argument("--no-browser", action="store_true")
|
|
134
|
+
p_start.add_argument("--budget", type=float, default=None)
|
|
135
|
+
|
|
136
|
+
sub.add_parser("status")
|
|
137
|
+
|
|
138
|
+
p_exp = sub.add_parser("export")
|
|
139
|
+
p_exp.add_argument("--format", choices=["csv", "json"], default="csv")
|
|
140
|
+
p_exp.add_argument("--days", type=int, default=30)
|
|
141
|
+
|
|
142
|
+
p_seed = sub.add_parser("seed")
|
|
143
|
+
p_seed.add_argument("--days", type=int, default=30)
|
|
144
|
+
p_seed.add_argument("--clean", action="store_true")
|
|
145
|
+
|
|
146
|
+
sub.add_parser("config")
|
|
147
|
+
|
|
148
|
+
p_lic = sub.add_parser("license")
|
|
149
|
+
p_lic.add_argument("--key", type=str, default=None)
|
|
150
|
+
|
|
151
|
+
args = parser.parse_args()
|
|
152
|
+
|
|
153
|
+
if args.command is None:
|
|
154
|
+
args.command = "start"
|
|
155
|
+
args.port = None
|
|
156
|
+
args.no_browser = False
|
|
157
|
+
args.budget = None
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
"start": cmd_start,
|
|
161
|
+
"status": cmd_status,
|
|
162
|
+
"export": cmd_export,
|
|
163
|
+
"seed": cmd_seed,
|
|
164
|
+
"config": cmd_config,
|
|
165
|
+
"license": cmd_license,
|
|
166
|
+
}[args.command](args)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|
tokenwatch/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
config.py — User configuration for TokenWatch.
|
|
3
|
+
|
|
4
|
+
Settings are stored in ~/.claude/tokenwatch_config.json
|
|
5
|
+
Edit the file directly, or use the /api/config endpoint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
CONFIG_FILE = Path.home() / ".claude" / "tokenwatch_config.json"
|
|
15
|
+
|
|
16
|
+
DEFAULTS: dict = {
|
|
17
|
+
"daily_budget": 10.00, # USD — alert thresholds are % of this
|
|
18
|
+
"alert_at_pct": [80, 100], # fire desktop notification at these %
|
|
19
|
+
"history_days": 30, # how many days of sessions to keep in memory
|
|
20
|
+
"port": 7842,
|
|
21
|
+
"open_browser": True, # open browser automatically on `tokenwatch` start
|
|
22
|
+
"digest_email": "", # leave blank to disable weekly email digest
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load() -> dict:
|
|
27
|
+
try:
|
|
28
|
+
if CONFIG_FILE.exists():
|
|
29
|
+
stored = json.loads(CONFIG_FILE.read_text())
|
|
30
|
+
# Merge with defaults so new keys are always present
|
|
31
|
+
return {**DEFAULTS, **stored}
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.warning(f"Could not read config: {e}")
|
|
34
|
+
return dict(DEFAULTS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save(cfg: dict) -> None:
|
|
38
|
+
try:
|
|
39
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
|
|
41
|
+
logger.info(f"Config saved to {CONFIG_FILE}")
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.warning(f"Could not save config: {e}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def update(key: str, value) -> dict:
|
|
47
|
+
cfg = load()
|
|
48
|
+
cfg[key] = value
|
|
49
|
+
save(cfg)
|
|
50
|
+
return cfg
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
license_check.py — License key validation for Pro features.
|
|
3
|
+
|
|
4
|
+
Free tier: full local dashboard, 30-day history, CSV export.
|
|
5
|
+
Pro tier ($9/mo): weekly email digest, team report endpoint, update priority.
|
|
6
|
+
|
|
7
|
+
Validation hits a lightweight endpoint on tokenwatch.dev.
|
|
8
|
+
If offline, falls back to local cache (grace period: 7 days).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
VALIDATION_URL = "https://api.tokenwatch.observer/api/validate" # your backend
|
|
21
|
+
CACHE_FILE = Path.home() / ".claude" / "tokenwatch_license.json"
|
|
22
|
+
GRACE_PERIOD = 7 * 86400 # 7 days offline grace
|
|
23
|
+
|
|
24
|
+
_is_pro: bool = False
|
|
25
|
+
_license_key: str = ""
|
|
26
|
+
_last_validated: float = 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_cache() -> dict:
|
|
30
|
+
try:
|
|
31
|
+
if CACHE_FILE.exists():
|
|
32
|
+
return json.loads(CACHE_FILE.read_text())
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _save_cache(key: str, valid: bool) -> None:
|
|
39
|
+
try:
|
|
40
|
+
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
CACHE_FILE.write_text(json.dumps({
|
|
42
|
+
"key": key,
|
|
43
|
+
"valid": valid,
|
|
44
|
+
"checked_at": time.time(),
|
|
45
|
+
}))
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate(key: str) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Validate a license key.
|
|
53
|
+
Returns {"valid": bool, "plan": str, "message": str}
|
|
54
|
+
"""
|
|
55
|
+
global _is_pro, _license_key, _last_validated
|
|
56
|
+
|
|
57
|
+
if not key or len(key) < 8:
|
|
58
|
+
return {"valid": False, "plan": "free", "message": "No license key."}
|
|
59
|
+
|
|
60
|
+
# Try live validation
|
|
61
|
+
try:
|
|
62
|
+
payload = json.dumps({"key": key, "version": "0.2.0"}).encode()
|
|
63
|
+
req = urllib.request.Request(
|
|
64
|
+
VALIDATION_URL,
|
|
65
|
+
data=payload,
|
|
66
|
+
headers={"Content-Type": "application/json", "User-Agent": "tokenwatch/0.2.0"},
|
|
67
|
+
method="POST",
|
|
68
|
+
)
|
|
69
|
+
with urllib.request.urlopen(req, timeout=6) as resp:
|
|
70
|
+
result = json.loads(resp.read())
|
|
71
|
+
|
|
72
|
+
valid = result.get("valid", False)
|
|
73
|
+
_is_pro = valid
|
|
74
|
+
_license_key = key
|
|
75
|
+
_last_validated = time.time()
|
|
76
|
+
_save_cache(key, valid)
|
|
77
|
+
return {
|
|
78
|
+
"valid": valid,
|
|
79
|
+
"plan": result.get("plan", "pro" if valid else "free"),
|
|
80
|
+
"message": result.get("message", ""),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.debug(f"License validation offline: {e}")
|
|
85
|
+
|
|
86
|
+
# Offline fallback: use cache within grace period
|
|
87
|
+
cache = _load_cache()
|
|
88
|
+
if cache.get("key") == key and cache.get("valid"):
|
|
89
|
+
age = time.time() - cache.get("checked_at", 0)
|
|
90
|
+
if age < GRACE_PERIOD:
|
|
91
|
+
_is_pro = True
|
|
92
|
+
_license_key = key
|
|
93
|
+
days_left = int((GRACE_PERIOD - age) / 86400)
|
|
94
|
+
return {
|
|
95
|
+
"valid": True,
|
|
96
|
+
"plan": "pro",
|
|
97
|
+
"message": f"Offline — validated from cache ({days_left}d grace remaining).",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {"valid": False, "plan": "free", "message": "Could not validate license (offline)."}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_pro() -> bool:
|
|
104
|
+
"""Quick check — use this to gate Pro features."""
|
|
105
|
+
return _is_pro
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_from_config() -> None:
|
|
109
|
+
"""Call on startup — loads key from config and validates silently."""
|
|
110
|
+
from tokenwatch import config as cfg_module
|
|
111
|
+
key = cfg_module.load().get("license_key", "")
|
|
112
|
+
if key:
|
|
113
|
+
validate(key)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_status() -> dict:
|
|
117
|
+
return {
|
|
118
|
+
"is_pro": _is_pro,
|
|
119
|
+
"plan": "pro" if _is_pro else "free",
|
|
120
|
+
"key_set": bool(_license_key),
|
|
121
|
+
"key_preview": (_license_key[:4] + "…" + _license_key[-4:]) if len(_license_key) >= 8 else "",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Pro-gated feature list (for the dashboard to show/hide UI)
|
|
126
|
+
PRO_FEATURES = ["weekly_digest", "team_report", "priority_support"]
|