@jeganwrites/claudash 1.0.0
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.
- package/CONTRIBUTING.md +35 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/analyzer.py +890 -0
- package/bin/claudash.js +121 -0
- package/claude_ai_tracker.py +358 -0
- package/cli.py +1034 -0
- package/config.py +100 -0
- package/db.py +1156 -0
- package/fix_tracker.py +539 -0
- package/insights.py +359 -0
- package/mcp_server.py +414 -0
- package/package.json +39 -0
- package/scanner.py +385 -0
- package/server.py +762 -0
- package/templates/accounts.html +936 -0
- package/templates/dashboard.html +1742 -0
- package/tools/get-derived-keys.py +112 -0
- package/tools/mac-sync.py +386 -0
- package/tools/oauth_sync.py +308 -0
- package/tools/setup-pm2.sh +53 -0
- package/waste_patterns.py +334 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claudash — derived-key helper.
|
|
3
|
+
|
|
4
|
+
If you can't (or don't want to) call `security find-generic-password`
|
|
5
|
+
from cron on macOS, this script extracts the per-browser AES keys once
|
|
6
|
+
using your current interactive login and prints them in a format you
|
|
7
|
+
can paste into an environment-variable block for mac-sync.py.
|
|
8
|
+
|
|
9
|
+
Intended usage:
|
|
10
|
+
|
|
11
|
+
1. Run this script once in your Mac terminal while logged in:
|
|
12
|
+
python3 tools/get-derived-keys.py
|
|
13
|
+
It will prompt for the Chrome/Vivaldi "Safe Storage" passwords
|
|
14
|
+
from the macOS keychain (you may have to click "Always Allow"
|
|
15
|
+
a couple of times), derive the PBKDF2 keys, and print:
|
|
16
|
+
|
|
17
|
+
export CLAUDASH_CHROME_KEY=0123abcd...
|
|
18
|
+
export CLAUDASH_VIVALDI_KEY=cafebabe...
|
|
19
|
+
|
|
20
|
+
2. Add those exports to your ~/.zshenv (or the environment block
|
|
21
|
+
of your cron entry).
|
|
22
|
+
|
|
23
|
+
3. Update mac-sync.py to read the key from the env var instead of
|
|
24
|
+
calling `security find-generic-password` on every run — that
|
|
25
|
+
way cron can run unattended without triggering the keychain
|
|
26
|
+
"allow/deny" prompt.
|
|
27
|
+
|
|
28
|
+
Runs on macOS only. Pure Python stdlib.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BROWSERS = [
|
|
37
|
+
("Chrome", "Chrome Safe Storage", "Chrome"),
|
|
38
|
+
("Vivaldi", "Vivaldi Safe Storage", "Vivaldi"),
|
|
39
|
+
("Edge", "Microsoft Edge Safe Storage", "Microsoft Edge"),
|
|
40
|
+
("Brave", "Brave Safe Storage", "Brave"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
PBKDF2_ITERATIONS = 1003
|
|
44
|
+
PBKDF2_SALT = b"saltysalt"
|
|
45
|
+
PBKDF2_KEY_LEN = 16
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fetch_safe_storage_password(service, account):
|
|
49
|
+
try:
|
|
50
|
+
raw = subprocess.check_output(
|
|
51
|
+
["security", "find-generic-password", "-w",
|
|
52
|
+
"-s", service, "-a", account],
|
|
53
|
+
stderr=subprocess.DEVNULL,
|
|
54
|
+
).strip()
|
|
55
|
+
return raw
|
|
56
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _derive_key(password):
|
|
61
|
+
if not password:
|
|
62
|
+
return None
|
|
63
|
+
return hashlib.pbkdf2_hmac(
|
|
64
|
+
"sha1", password, PBKDF2_SALT, PBKDF2_ITERATIONS, dklen=PBKDF2_KEY_LEN,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
if sys.platform != "darwin":
|
|
70
|
+
print("ERROR: this helper only runs on macOS.", file=sys.stderr)
|
|
71
|
+
print("On Linux/Windows, Chromium cookies use a different scheme.", file=sys.stderr)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
print("Claudash — derived browser key extractor")
|
|
75
|
+
print("=" * 50)
|
|
76
|
+
print()
|
|
77
|
+
print("Keychain will prompt for each browser the first time —")
|
|
78
|
+
print("click 'Always Allow' so future runs are silent.")
|
|
79
|
+
print()
|
|
80
|
+
|
|
81
|
+
exports = []
|
|
82
|
+
for display, service, account in BROWSERS:
|
|
83
|
+
pw = _fetch_safe_storage_password(service, account)
|
|
84
|
+
if pw is None:
|
|
85
|
+
print(f" {display}: not installed or no keychain entry — skipped")
|
|
86
|
+
continue
|
|
87
|
+
key = _derive_key(pw)
|
|
88
|
+
if key is None:
|
|
89
|
+
print(f" {display}: empty password — skipped")
|
|
90
|
+
continue
|
|
91
|
+
env_name = f"CLAUDASH_{display.upper()}_KEY"
|
|
92
|
+
exports.append((env_name, key.hex()))
|
|
93
|
+
print(f" {display}: key derived ({len(key)*8}-bit)")
|
|
94
|
+
|
|
95
|
+
if not exports:
|
|
96
|
+
print()
|
|
97
|
+
print("No browser keys could be extracted. Are Chrome / Vivaldi / Edge / Brave installed?")
|
|
98
|
+
sys.exit(2)
|
|
99
|
+
|
|
100
|
+
print()
|
|
101
|
+
print("Add these to your shell profile (~/.zshenv or ~/.bash_profile):")
|
|
102
|
+
print()
|
|
103
|
+
print("# Claudash — pre-derived browser cookie keys (safe to commit to private dotfiles)")
|
|
104
|
+
for name, hex_key in exports:
|
|
105
|
+
print(f"export {name}={hex_key}")
|
|
106
|
+
print()
|
|
107
|
+
print("Then update mac-sync.py to read os.environ.get(name) instead of")
|
|
108
|
+
print("calling `security find-generic-password` every run.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
mac-sync.py — Push claude.ai usage data from Mac browsers to VPS dashboard.
|
|
4
|
+
|
|
5
|
+
Flow: Mac polls claude.ai locally -> pushes usage data to VPS -> VPS stores it.
|
|
6
|
+
The VPS never contacts claude.ai directly.
|
|
7
|
+
|
|
8
|
+
Pure Python stdlib. Zero pip deps. Runs on macOS only.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 mac-sync.py
|
|
12
|
+
|
|
13
|
+
Setup:
|
|
14
|
+
1. Download this file from your dashboard (SSH tunnel, then):
|
|
15
|
+
curl http://localhost:8080/tools/mac-sync.py -o mac-sync.py
|
|
16
|
+
The file is served WITHOUT the sync token pre-filled (by design — it
|
|
17
|
+
used to be injected server-side, which leaked the token to any caller).
|
|
18
|
+
2. Retrieve your sync token on the server:
|
|
19
|
+
ssh user@YOUR_VPS_IP
|
|
20
|
+
cd ~/claudash
|
|
21
|
+
python3 cli.py claude-ai --sync-token
|
|
22
|
+
3. Paste the token into SYNC_TOKEN below.
|
|
23
|
+
4. Run: python3 mac-sync.py
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import sqlite3
|
|
30
|
+
import ssl
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
import hashlib
|
|
35
|
+
from urllib.request import Request, urlopen
|
|
36
|
+
from urllib.error import HTTPError, URLError
|
|
37
|
+
|
|
38
|
+
# ── Configuration ──
|
|
39
|
+
# Set VPS_IP to your Claudash server's IP, or "localhost" if you're running
|
|
40
|
+
# via SSH tunnel (ssh -L 8080:localhost:8080 user@your-server).
|
|
41
|
+
VPS_IP = "localhost"
|
|
42
|
+
VPS_PORT = 8080
|
|
43
|
+
SYNC_TOKEN = ""
|
|
44
|
+
|
|
45
|
+
# Browser configs: (name, cookie_db_path_suffix, keychain_service, keychain_account)
|
|
46
|
+
BROWSERS = [
|
|
47
|
+
("Chrome", "Google/Chrome/Default/Cookies", "Chrome Safe Storage", "Chrome"),
|
|
48
|
+
("Vivaldi", "Vivaldi/Default/Cookies", "Vivaldi Safe Storage", "Vivaldi"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
CLAUDE_DOMAIN = ".claude.ai"
|
|
52
|
+
COOKIE_NAME = "sessionKey"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
if not SYNC_TOKEN:
|
|
57
|
+
print("ERROR: SYNC_TOKEN is empty.", file=sys.stderr)
|
|
58
|
+
print("", file=sys.stderr)
|
|
59
|
+
print("To fix:", file=sys.stderr)
|
|
60
|
+
print(" 1. On your VPS, run: python3 cli.py claude-ai --sync-token", file=sys.stderr)
|
|
61
|
+
print(" 2. Paste the token into the SYNC_TOKEN variable at the top of this file", file=sys.stderr)
|
|
62
|
+
print(" 3. Or re-download from: http://{}:{}/tools/mac-sync.py".format(VPS_IP, VPS_PORT), file=sys.stderr)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
if sys.platform != "darwin":
|
|
66
|
+
print("ERROR: mac-sync.py requires macOS (uses macOS Keychain for cookie decryption)", file=sys.stderr)
|
|
67
|
+
print("For Claude Code users on any platform, use tools/oauth_sync.py instead", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
app_support = os.path.expanduser("~/Library/Application Support")
|
|
71
|
+
pushed = 0
|
|
72
|
+
|
|
73
|
+
for browser_name, cookie_suffix, keychain_service, keychain_account in BROWSERS:
|
|
74
|
+
cookie_db = os.path.join(app_support, cookie_suffix)
|
|
75
|
+
if not os.path.exists(cookie_db):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
encrypted = _read_cookie(cookie_db, browser_name)
|
|
79
|
+
if encrypted is None:
|
|
80
|
+
print(f"{browser_name}: no sessionKey found for {CLAUDE_DOMAIN}")
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
session_key = decrypt_cookie(encrypted, browser_name)
|
|
84
|
+
if not session_key:
|
|
85
|
+
print(f"{browser_name}: sessionKey found but decryption failed")
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Verify session key and get account info
|
|
89
|
+
account_info = _verify_with_claude(session_key)
|
|
90
|
+
if not account_info:
|
|
91
|
+
print(f"{browser_name}: sessionKey decrypted but could not verify with claude.ai")
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
email = account_info.get("email", "unknown")
|
|
95
|
+
org_id = account_info.get("org_id", "")
|
|
96
|
+
plan = account_info.get("plan", "unknown")
|
|
97
|
+
|
|
98
|
+
# Fetch usage data locally (Mac -> claude.ai)
|
|
99
|
+
usage = _fetch_usage(session_key, org_id, plan)
|
|
100
|
+
if usage:
|
|
101
|
+
print(f"{browser_name}: {email} ({plan}) — {usage.get('pct_used', 0):.1f}% window used")
|
|
102
|
+
else:
|
|
103
|
+
print(f"{browser_name}: {email} ({plan}) — could not fetch usage (pushing session only)")
|
|
104
|
+
|
|
105
|
+
# Push session key + usage data to VPS
|
|
106
|
+
ok = _push_to_vps(session_key, org_id, browser_name, email, usage)
|
|
107
|
+
if ok:
|
|
108
|
+
print(f"{browser_name}: {email} ({plan}) -> pushed OK")
|
|
109
|
+
pushed += 1
|
|
110
|
+
else:
|
|
111
|
+
print(f"{browser_name}: {email} ({plan}) -> push FAILED")
|
|
112
|
+
|
|
113
|
+
if pushed == 0:
|
|
114
|
+
print("\nNo session keys were pushed. Check that you're logged into claude.ai in Chrome or Vivaldi.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_cookie(cookie_db, browser_name):
|
|
118
|
+
"""Read the encrypted_value for sessionKey from a browser cookie DB."""
|
|
119
|
+
tmp = tempfile.mktemp(suffix=".db")
|
|
120
|
+
try:
|
|
121
|
+
shutil.copy2(cookie_db, tmp)
|
|
122
|
+
for ext in ("-wal", "-shm"):
|
|
123
|
+
src = cookie_db + ext
|
|
124
|
+
if os.path.exists(src):
|
|
125
|
+
shutil.copy2(src, tmp + ext)
|
|
126
|
+
except OSError as e:
|
|
127
|
+
print(f"{browser_name}: cannot copy cookie DB: {e}", file=sys.stderr)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
conn = sqlite3.connect(tmp)
|
|
132
|
+
row = conn.execute(
|
|
133
|
+
"SELECT encrypted_value FROM cookies "
|
|
134
|
+
"WHERE host_key = ? AND name = ? "
|
|
135
|
+
"ORDER BY last_access_utc DESC LIMIT 1",
|
|
136
|
+
(CLAUDE_DOMAIN, COOKIE_NAME),
|
|
137
|
+
).fetchone()
|
|
138
|
+
conn.close()
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"{browser_name}: cannot read cookie DB: {e}", file=sys.stderr)
|
|
141
|
+
return None
|
|
142
|
+
finally:
|
|
143
|
+
for f in (tmp, tmp + "-wal", tmp + "-shm"):
|
|
144
|
+
try:
|
|
145
|
+
os.unlink(f)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
if not row or not row[0]:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
return row[0]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decrypt_cookie(encrypted_value, browser):
|
|
156
|
+
"""Decrypt a Chrome/Vivaldi v10 encrypted cookie value."""
|
|
157
|
+
if not encrypted_value:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
if encrypted_value[:3] == b"v10":
|
|
161
|
+
encrypted_value = encrypted_value[3:]
|
|
162
|
+
else:
|
|
163
|
+
try:
|
|
164
|
+
return encrypted_value.decode("utf-8")
|
|
165
|
+
except Exception:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
browser_name = "Vivaldi" if "vivaldi" in browser.lower() else "Chrome"
|
|
169
|
+
try:
|
|
170
|
+
key_password = subprocess.check_output(
|
|
171
|
+
["security", "find-generic-password", "-w",
|
|
172
|
+
"-s", f"{browser_name} Safe Storage", "-a", browser_name],
|
|
173
|
+
stderr=subprocess.DEVNULL,
|
|
174
|
+
).strip()
|
|
175
|
+
except Exception:
|
|
176
|
+
print(f" {browser_name}: cannot read keychain password", file=sys.stderr)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
key = hashlib.pbkdf2_hmac("sha1", key_password, b"saltysalt", 1003, dklen=16)
|
|
180
|
+
iv = b" " * 16
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
184
|
+
f.write(encrypted_value)
|
|
185
|
+
tmp = f.name
|
|
186
|
+
result = subprocess.check_output(
|
|
187
|
+
["openssl", "enc", "-d", "-aes-128-cbc",
|
|
188
|
+
"-K", key.hex(), "-iv", iv.hex(), "-in", tmp, "-nosalt"],
|
|
189
|
+
stderr=subprocess.DEVNULL,
|
|
190
|
+
)
|
|
191
|
+
os.unlink(tmp)
|
|
192
|
+
marker = b"sk-ant-"
|
|
193
|
+
idx = result.find(marker)
|
|
194
|
+
if idx == -1:
|
|
195
|
+
return None
|
|
196
|
+
end = result.find(b"\x00", idx)
|
|
197
|
+
if end == -1:
|
|
198
|
+
end = len(result)
|
|
199
|
+
return result[idx:end].decode("utf-8").strip()
|
|
200
|
+
except subprocess.CalledProcessError:
|
|
201
|
+
try:
|
|
202
|
+
os.unlink(tmp)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
except Exception:
|
|
206
|
+
try:
|
|
207
|
+
os.unlink(tmp)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _claude_headers(session_key):
|
|
215
|
+
return {
|
|
216
|
+
"Accept": "application/json",
|
|
217
|
+
"Cookie": f"sessionKey={session_key}",
|
|
218
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parse_iso(ts_str):
|
|
223
|
+
if not ts_str:
|
|
224
|
+
return 0
|
|
225
|
+
try:
|
|
226
|
+
from datetime import datetime, timezone
|
|
227
|
+
clean = ts_str.replace("Z", "").replace("+00:00", "")
|
|
228
|
+
if "." in clean:
|
|
229
|
+
clean = clean.split(".")[0]
|
|
230
|
+
dt = datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S")
|
|
231
|
+
return int(dt.replace(tzinfo=timezone.utc).timestamp())
|
|
232
|
+
except Exception:
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _verify_with_claude(session_key):
|
|
237
|
+
"""Verify session key with claude.ai and extract account info."""
|
|
238
|
+
try:
|
|
239
|
+
req = Request("https://claude.ai/api/account")
|
|
240
|
+
for k, v in _claude_headers(session_key).items():
|
|
241
|
+
req.add_header(k, v)
|
|
242
|
+
resp = urlopen(req, timeout=10)
|
|
243
|
+
if resp.status == 200:
|
|
244
|
+
data = json.loads(resp.read().decode())
|
|
245
|
+
email = data.get("email_address", "")
|
|
246
|
+
org_id = ""
|
|
247
|
+
memberships = data.get("memberships", [])
|
|
248
|
+
if memberships:
|
|
249
|
+
org_id = memberships[0].get("organization", {}).get("uuid", "")
|
|
250
|
+
plan = "unknown"
|
|
251
|
+
for m in memberships:
|
|
252
|
+
org_obj = m.get("organization", {})
|
|
253
|
+
caps = org_obj.get("capabilities", [])
|
|
254
|
+
if isinstance(caps, list):
|
|
255
|
+
for c in caps:
|
|
256
|
+
if "max" in str(c).lower():
|
|
257
|
+
plan = "max"
|
|
258
|
+
break
|
|
259
|
+
if "pro" in str(c).lower():
|
|
260
|
+
plan = "pro"
|
|
261
|
+
break
|
|
262
|
+
if plan != "unknown":
|
|
263
|
+
break
|
|
264
|
+
return {"email": email, "org_id": org_id, "plan": plan}
|
|
265
|
+
except HTTPError:
|
|
266
|
+
return None
|
|
267
|
+
except Exception:
|
|
268
|
+
return None
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _fetch_usage(session_key, org_id, plan):
|
|
273
|
+
"""Fetch usage from claude.ai locally on Mac.
|
|
274
|
+
Actual API response format:
|
|
275
|
+
{
|
|
276
|
+
"five_hour": {"utilization": 61.0, "resets_at": "2026-04-10T15:00:00Z"},
|
|
277
|
+
"seven_day": {"utilization": 49.0, "resets_at": "2026-04-14T12:00:00Z"},
|
|
278
|
+
"extra_usage": {"monthly_limit": 5000, "used_credits": 133.0, "utilization": 2.66}
|
|
279
|
+
}
|
|
280
|
+
"""
|
|
281
|
+
if not org_id:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
url = f"https://claude.ai/api/organizations/{org_id}/usage"
|
|
285
|
+
try:
|
|
286
|
+
req = Request(url)
|
|
287
|
+
for k, v in _claude_headers(session_key).items():
|
|
288
|
+
req.add_header(k, v)
|
|
289
|
+
resp = urlopen(req, timeout=10)
|
|
290
|
+
data = json.loads(resp.read().decode())
|
|
291
|
+
except HTTPError as e:
|
|
292
|
+
print(f" Usage API returned HTTP {e.code}", file=sys.stderr)
|
|
293
|
+
return None
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print(f" Usage API error: {e}", file=sys.stderr)
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
raw = json.dumps(data)
|
|
299
|
+
|
|
300
|
+
five_hour = data.get("five_hour", {})
|
|
301
|
+
seven_day = data.get("seven_day", {})
|
|
302
|
+
extra = data.get("extra_usage", {})
|
|
303
|
+
|
|
304
|
+
pct_used = five_hour.get("utilization", 0.0)
|
|
305
|
+
resets_at = five_hour.get("resets_at", "")
|
|
306
|
+
|
|
307
|
+
# Parse window times
|
|
308
|
+
window_end = None
|
|
309
|
+
if resets_at:
|
|
310
|
+
from datetime import datetime, timezone
|
|
311
|
+
try:
|
|
312
|
+
window_end = int(datetime.fromisoformat(
|
|
313
|
+
resets_at.replace("Z", "+00:00")
|
|
314
|
+
).timestamp())
|
|
315
|
+
except Exception:
|
|
316
|
+
window_end = _parse_iso(resets_at)
|
|
317
|
+
window_start = window_end - 18000 if window_end else None # 5hr = 18000s
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
"pct_used": pct_used,
|
|
321
|
+
"five_hour_utilization": pct_used,
|
|
322
|
+
"seven_day_utilization": seven_day.get("utilization", 0.0),
|
|
323
|
+
"extra_credits_used": extra.get("used_credits", 0.0),
|
|
324
|
+
"extra_credits_limit": extra.get("monthly_limit", 0.0),
|
|
325
|
+
"extra_utilization": extra.get("utilization", 0.0),
|
|
326
|
+
"window_start": window_start,
|
|
327
|
+
"window_end": window_end,
|
|
328
|
+
"tokens_used": int(pct_used * 10000), # normalized estimate
|
|
329
|
+
"tokens_limit": 1000000,
|
|
330
|
+
"messages_used": 0,
|
|
331
|
+
"messages_limit": 0,
|
|
332
|
+
"plan": plan,
|
|
333
|
+
"raw": raw,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _push_to_vps(session_key, org_id, browser, account_hint, usage):
|
|
338
|
+
"""Push session key + usage data to VPS dashboard sync endpoint."""
|
|
339
|
+
url = f"http://{VPS_IP}:{VPS_PORT}/api/claude-ai/sync"
|
|
340
|
+
payload = {
|
|
341
|
+
"session_key": session_key,
|
|
342
|
+
"org_id": org_id,
|
|
343
|
+
"browser": browser,
|
|
344
|
+
"account_hint": account_hint,
|
|
345
|
+
}
|
|
346
|
+
if usage:
|
|
347
|
+
payload["usage"] = usage
|
|
348
|
+
|
|
349
|
+
body = json.dumps(payload).encode("utf-8")
|
|
350
|
+
headers = {
|
|
351
|
+
"Content-Type": "application/json",
|
|
352
|
+
"X-Sync-Token": SYNC_TOKEN,
|
|
353
|
+
}
|
|
354
|
+
req = Request(url, data=body, headers=headers, method="POST")
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
with urlopen(req, timeout=15) as resp:
|
|
358
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
359
|
+
return data.get("success", False)
|
|
360
|
+
except HTTPError as e:
|
|
361
|
+
try:
|
|
362
|
+
err = json.loads(e.read().decode("utf-8"))
|
|
363
|
+
print(f" Sync error: {err.get('error', e.code)}", file=sys.stderr)
|
|
364
|
+
except Exception:
|
|
365
|
+
print(f" Sync error: HTTP {e.code}", file=sys.stderr)
|
|
366
|
+
return False
|
|
367
|
+
except (URLError, OSError) as e:
|
|
368
|
+
print(f" Cannot reach VPS at {VPS_IP}:{VPS_PORT}: {e}", file=sys.stderr)
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
main()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ── How to get your sync token ──
|
|
377
|
+
#
|
|
378
|
+
# The dashboard no longer injects the token into this file at download time
|
|
379
|
+
# (that used to leak the token to anyone who hit /tools/mac-sync.py).
|
|
380
|
+
#
|
|
381
|
+
# To get your token:
|
|
382
|
+
# ssh user@YOUR_VPS_IP
|
|
383
|
+
# cd ~/claudash
|
|
384
|
+
# python3 cli.py claude-ai --sync-token
|
|
385
|
+
#
|
|
386
|
+
# Then paste the value into SYNC_TOKEN at the top of this file.
|