@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.
@@ -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.