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 ADDED
@@ -0,0 +1,2 @@
1
+ """TokenWatch — Real-time Claude Code cost dashboard."""
2
+ __version__ = "0.2.0"
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"]