tokenwatch 0.2.0__tar.gz
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-0.2.0/PKG-INFO +124 -0
- tokenwatch-0.2.0/README.md +106 -0
- tokenwatch-0.2.0/pyproject.toml +34 -0
- tokenwatch-0.2.0/setup.cfg +4 -0
- tokenwatch-0.2.0/tokenwatch/__init__.py +2 -0
- tokenwatch-0.2.0/tokenwatch/alerts.py +142 -0
- tokenwatch-0.2.0/tokenwatch/cli.py +170 -0
- tokenwatch-0.2.0/tokenwatch/config.py +50 -0
- tokenwatch-0.2.0/tokenwatch/license_check.py +126 -0
- tokenwatch-0.2.0/tokenwatch/main.py +263 -0
- tokenwatch-0.2.0/tokenwatch/parser.py +291 -0
- tokenwatch-0.2.0/tokenwatch/pricing.py +198 -0
- tokenwatch-0.2.0/tokenwatch/seed_demo.py +148 -0
- tokenwatch-0.2.0/tokenwatch/static/index.html +604 -0
- tokenwatch-0.2.0/tokenwatch/test_parser.py +235 -0
- tokenwatch-0.2.0/tokenwatch/updater.py +54 -0
- tokenwatch-0.2.0/tokenwatch/watcher.py +56 -0
- tokenwatch-0.2.0/tokenwatch-backend/main.py +317 -0
- tokenwatch-0.2.0/tokenwatch.egg-info/PKG-INFO +124 -0
- tokenwatch-0.2.0/tokenwatch.egg-info/SOURCES.txt +22 -0
- tokenwatch-0.2.0/tokenwatch.egg-info/dependency_links.txt +1 -0
- tokenwatch-0.2.0/tokenwatch.egg-info/entry_points.txt +2 -0
- tokenwatch-0.2.0/tokenwatch.egg-info/requires.txt +8 -0
- tokenwatch-0.2.0/tokenwatch.egg-info/top_level.txt +3 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tokenwatch
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Real-time Claude Code cost dashboard — know exactly what you're spending
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://tokenwatch.dev
|
|
7
|
+
Project-URL: Issues, https://github.com/yourusername/tokenwatch/issues
|
|
8
|
+
Keywords: claude,anthropic,llm,cost,tokens,dashboard
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: fastapi>=0.111
|
|
12
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
13
|
+
Requires-Dist: watchdog>=4.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
16
|
+
Requires-Dist: build; extra == "dev"
|
|
17
|
+
Requires-Dist: twine; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# TokenWatch
|
|
20
|
+
|
|
21
|
+
> Real-time Claude Code cost dashboard. Know exactly what you're spending — per session, per project, per day.
|
|
22
|
+
|
|
23
|
+
No proxy. No SDK wrapping. No API key required.
|
|
24
|
+
Reads Claude Code's local session logs directly.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install tokenwatch
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
tokenwatch
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Opens `http://localhost:7842` automatically. That's it.
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
tokenwatch # start dashboard + open browser
|
|
46
|
+
tokenwatch start # same as above
|
|
47
|
+
tokenwatch status # print today's cost in terminal
|
|
48
|
+
tokenwatch export # export 30 days to CSV
|
|
49
|
+
tokenwatch export --format json --days 90
|
|
50
|
+
tokenwatch seed # generate demo data (no real logs needed)
|
|
51
|
+
tokenwatch seed --clean # remove demo data
|
|
52
|
+
tokenwatch config # show current settings
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
tokenwatch --budget 20 # set $20/day budget
|
|
59
|
+
tokenwatch --port 8080 # different port
|
|
60
|
+
tokenwatch --no-browser # don't open browser
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **Live cost meter** — updates in real time as Claude Code responds
|
|
66
|
+
- **Daily budget bar** — turns orange at 80%, red at 100%
|
|
67
|
+
- **Desktop notifications** — alerts at configurable thresholds (macOS, Linux)
|
|
68
|
+
- **Session drill-down** — click any session to see per-message cost breakdown
|
|
69
|
+
- **Project breakdown** — doughnut chart showing cost by project
|
|
70
|
+
- **CSV/JSON export** — one-click download
|
|
71
|
+
- **Live pricing** — fetched from Anthropic on startup, cached locally
|
|
72
|
+
- **Weekly email digest** — optional (requires `RESEND_API_KEY`)
|
|
73
|
+
- **Auto-start on login** — run `bash install_autostart.sh` once (macOS)
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
Settings live in `~/.claude/tokenwatch_config.json`. Edit directly or use the ⚙ button in the dashboard.
|
|
78
|
+
|
|
79
|
+
| Key | Default | Description |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `daily_budget` | 10.00 | Daily spend limit (USD) |
|
|
82
|
+
| `alert_at_pct` | [80, 100] | Notification thresholds |
|
|
83
|
+
| `history_days` | 30 | Days of history to show |
|
|
84
|
+
| `port` | 7842 | Server port |
|
|
85
|
+
| `open_browser` | true | Auto-open on start |
|
|
86
|
+
| `digest_email` | "" | Weekly email (needs RESEND_API_KEY) |
|
|
87
|
+
|
|
88
|
+
## Weekly digest email
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
export RESEND_API_KEY=re_...
|
|
92
|
+
tokenwatch # set digest_email in Settings
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Auto-start on macOS login
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
bash install_autostart.sh
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
Claude Code writes JSONL session logs to `~/.claude/projects/<project>/<session>.jsonl`.
|
|
104
|
+
TokenWatch watches that directory with OS-native file events (FSEvents on macOS, inotify on Linux),
|
|
105
|
+
reads new entries within 300ms, and streams cost updates to the dashboard via Server-Sent Events.
|
|
106
|
+
|
|
107
|
+
Costs = token counts × live Anthropic prices. Cache tokens are priced correctly (10× cheaper than input).
|
|
108
|
+
|
|
109
|
+
## Publishing to PyPI
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Tag a release
|
|
113
|
+
git tag v0.2.0 && git push --tags
|
|
114
|
+
# GitHub Actions auto-publishes via trusted publishing
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or manually:
|
|
118
|
+
```bash
|
|
119
|
+
make publish
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# TokenWatch
|
|
2
|
+
|
|
3
|
+
> Real-time Claude Code cost dashboard. Know exactly what you're spending — per session, per project, per day.
|
|
4
|
+
|
|
5
|
+
No proxy. No SDK wrapping. No API key required.
|
|
6
|
+
Reads Claude Code's local session logs directly.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install tokenwatch
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Run
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
tokenwatch
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Opens `http://localhost:7842` automatically. That's it.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
tokenwatch # start dashboard + open browser
|
|
28
|
+
tokenwatch start # same as above
|
|
29
|
+
tokenwatch status # print today's cost in terminal
|
|
30
|
+
tokenwatch export # export 30 days to CSV
|
|
31
|
+
tokenwatch export --format json --days 90
|
|
32
|
+
tokenwatch seed # generate demo data (no real logs needed)
|
|
33
|
+
tokenwatch seed --clean # remove demo data
|
|
34
|
+
tokenwatch config # show current settings
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Options
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
tokenwatch --budget 20 # set $20/day budget
|
|
41
|
+
tokenwatch --port 8080 # different port
|
|
42
|
+
tokenwatch --no-browser # don't open browser
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Live cost meter** — updates in real time as Claude Code responds
|
|
48
|
+
- **Daily budget bar** — turns orange at 80%, red at 100%
|
|
49
|
+
- **Desktop notifications** — alerts at configurable thresholds (macOS, Linux)
|
|
50
|
+
- **Session drill-down** — click any session to see per-message cost breakdown
|
|
51
|
+
- **Project breakdown** — doughnut chart showing cost by project
|
|
52
|
+
- **CSV/JSON export** — one-click download
|
|
53
|
+
- **Live pricing** — fetched from Anthropic on startup, cached locally
|
|
54
|
+
- **Weekly email digest** — optional (requires `RESEND_API_KEY`)
|
|
55
|
+
- **Auto-start on login** — run `bash install_autostart.sh` once (macOS)
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
Settings live in `~/.claude/tokenwatch_config.json`. Edit directly or use the ⚙ button in the dashboard.
|
|
60
|
+
|
|
61
|
+
| Key | Default | Description |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `daily_budget` | 10.00 | Daily spend limit (USD) |
|
|
64
|
+
| `alert_at_pct` | [80, 100] | Notification thresholds |
|
|
65
|
+
| `history_days` | 30 | Days of history to show |
|
|
66
|
+
| `port` | 7842 | Server port |
|
|
67
|
+
| `open_browser` | true | Auto-open on start |
|
|
68
|
+
| `digest_email` | "" | Weekly email (needs RESEND_API_KEY) |
|
|
69
|
+
|
|
70
|
+
## Weekly digest email
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
export RESEND_API_KEY=re_...
|
|
74
|
+
tokenwatch # set digest_email in Settings
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Auto-start on macOS login
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bash install_autostart.sh
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
Claude Code writes JSONL session logs to `~/.claude/projects/<project>/<session>.jsonl`.
|
|
86
|
+
TokenWatch watches that directory with OS-native file events (FSEvents on macOS, inotify on Linux),
|
|
87
|
+
reads new entries within 300ms, and streams cost updates to the dashboard via Server-Sent Events.
|
|
88
|
+
|
|
89
|
+
Costs = token counts × live Anthropic prices. Cache tokens are priced correctly (10× cheaper than input).
|
|
90
|
+
|
|
91
|
+
## Publishing to PyPI
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Tag a release
|
|
95
|
+
git tag v0.2.0 && git push --tags
|
|
96
|
+
# GitHub Actions auto-publishes via trusted publishing
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or manually:
|
|
100
|
+
```bash
|
|
101
|
+
make publish
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tokenwatch"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Real-time Claude Code cost dashboard — know exactly what you're spending"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["claude", "anthropic", "llm", "cost", "tokens", "dashboard"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"fastapi>=0.111",
|
|
15
|
+
"uvicorn[standard]>=0.29",
|
|
16
|
+
"watchdog>=4.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=8", "build", "twine"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
tokenwatch = "tokenwatch.cli:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://tokenwatch.dev"
|
|
27
|
+
Issues = "https://github.com/yourusername/tokenwatch/issues"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["."]
|
|
31
|
+
include = ["tokenwatch*"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
"tokenwatch" = ["static/*"]
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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
|