@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/server.py ADDED
@@ -0,0 +1,762 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import re
5
+ import time
6
+ import threading
7
+ from datetime import datetime, timezone
8
+ from http.server import HTTPServer, BaseHTTPRequestHandler
9
+ from http.server import ThreadingHTTPServer
10
+ from urllib.parse import urlparse, parse_qs
11
+
12
+ from db import (
13
+ get_conn, query_alerts, get_session_count, get_db_size_mb,
14
+ get_latest_claude_ai_usage, get_claude_ai_history,
15
+ get_insights, dismiss_insight, get_daily_snapshots, get_window_burns,
16
+ get_all_accounts, get_account_projects, get_accounts_config,
17
+ create_account, update_account, delete_account,
18
+ add_account_project, remove_account_project,
19
+ get_claude_ai_accounts_all, get_claude_ai_account,
20
+ get_latest_claude_ai_snapshot, get_claude_ai_snapshot_history,
21
+ clear_claude_ai_session,
22
+ get_setting,
23
+ upsert_claude_ai_account, update_claude_ai_account_status,
24
+ insert_claude_ai_snapshot,
25
+ get_real_story_insights,
26
+ )
27
+
28
+ PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
29
+ from analyzer import full_analysis, project_metrics, window_intelligence, trend_metrics
30
+ from scanner import scan_all, get_last_scan_time, preview_paths, discover_claude_paths
31
+ from insights import generate_insights
32
+ from claude_ai_tracker import (
33
+ poll_all as poll_claude_ai, get_account_statuses, get_last_poll_time,
34
+ setup_account as tracker_setup_account, poll_single as tracker_poll_single,
35
+ )
36
+
37
+ TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
38
+
39
+ # Response cache for /api/data — {account: (timestamp, result)}
40
+ _data_cache = {}
41
+ CACHE_TTL = 30 # seconds
42
+ _server_start_time = time.time()
43
+
44
+
45
+ class DashboardHandler(BaseHTTPRequestHandler):
46
+ def do_GET(self):
47
+ parsed = urlparse(self.path)
48
+ path = parsed.path
49
+ params = parse_qs(parsed.query)
50
+
51
+ if path == "/" or path == "/index.html":
52
+ self._serve_template("dashboard.html")
53
+
54
+ elif path == "/accounts":
55
+ self._serve_template("accounts.html")
56
+
57
+ elif path == "/api/data":
58
+ account = params.get("account", ["all"])[0]
59
+ self._serve_json(self._get_data(account))
60
+
61
+ elif path == "/api/projects":
62
+ account = params.get("account", ["all"])[0]
63
+ conn = get_conn()
64
+ data = project_metrics(conn, account)
65
+ conn.close()
66
+ self._serve_json(data)
67
+
68
+ elif path == "/api/insights":
69
+ account = params.get("account", [None])[0]
70
+ dismissed = int(params.get("dismissed", ["0"])[0])
71
+ conn = get_conn()
72
+ rows = get_insights(conn, account, dismissed)
73
+ conn.close()
74
+ self._serve_json([dict(r) for r in rows])
75
+
76
+ elif path == "/api/window":
77
+ account = params.get("account", ["personal_max"])[0]
78
+ conn = get_conn()
79
+ data = window_intelligence(conn, account)
80
+ conn.close()
81
+ self._serve_json(data)
82
+
83
+ elif path == "/api/trends":
84
+ account = params.get("account", ["all"])[0]
85
+ days = int(params.get("days", ["7"])[0])
86
+ conn = get_conn()
87
+ data = trend_metrics(conn, account, days)
88
+ conn.close()
89
+ self._serve_json(data)
90
+
91
+ elif path == "/api/alerts":
92
+ conn = get_conn()
93
+ alerts = [dict(r) for r in query_alerts(conn)]
94
+ conn.close()
95
+ self._serve_json(alerts)
96
+
97
+ elif path == "/api/claude-ai":
98
+ conn = get_conn()
99
+ latest = [dict(r) for r in get_latest_claude_ai_usage(conn)]
100
+ history = [dict(r) for r in get_claude_ai_history(conn)]
101
+ statuses = get_account_statuses()
102
+ conn.close()
103
+ self._serve_json({
104
+ "accounts": latest,
105
+ "history": history,
106
+ "statuses": statuses,
107
+ "last_poll": get_last_poll_time(),
108
+ })
109
+
110
+ elif path == "/api/health":
111
+ conn = get_conn()
112
+ total = get_session_count(conn)
113
+ accounts = get_accounts_config(conn)
114
+ conn.close()
115
+ self._serve_json({
116
+ "db_size_mb": get_db_size_mb(),
117
+ "total_records": total,
118
+ "last_scan": get_last_scan_time(),
119
+ "accounts_active": list(accounts.keys()),
120
+ })
121
+
122
+ elif path == "/api/real-story":
123
+ import time as _time
124
+ stories = get_real_story_insights()
125
+ conn = get_conn()
126
+ total = get_session_count(conn)
127
+ conn.close()
128
+ self._serve_json({
129
+ "stories": stories,
130
+ "generated_at": int(_time.time()),
131
+ "sessions_analyzed": total,
132
+ "date_range_days": 30,
133
+ })
134
+
135
+ # ── Account management GET endpoints ──
136
+ elif path == "/api/accounts":
137
+ conn = get_conn()
138
+ accounts = get_all_accounts(conn)
139
+ # Add session stats per account
140
+ for a in accounts:
141
+ row = conn.execute(
142
+ "SELECT COUNT(DISTINCT session_id) as cnt, COALESCE(SUM(cost_usd),0) as cost "
143
+ "FROM sessions WHERE account = ? AND timestamp >= ?",
144
+ (a["account_id"], int((__import__("time").time()) - 30 * 86400)),
145
+ ).fetchone()
146
+ a["sessions_30d"] = row["cnt"] if row else 0
147
+ a["cost_30d"] = round(row["cost"], 2) if row else 0
148
+ conn.close()
149
+ self._serve_json(accounts)
150
+
151
+ elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path):
152
+ m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path)
153
+ account_id = m.group(1)
154
+ conn = get_conn()
155
+ data = get_account_projects(conn, account_id)
156
+ conn.close()
157
+ self._serve_json(data)
158
+
159
+ elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/preview$", path):
160
+ m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/preview$", path)
161
+ account_id = m.group(1)
162
+ conn = get_conn()
163
+ row = conn.execute("SELECT data_paths FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
164
+ conn.close()
165
+ if not row:
166
+ self._serve_json({"error": "account not found"}, 404)
167
+ return
168
+ paths = json.loads(row["data_paths"]) if row["data_paths"] else []
169
+ info = preview_paths(paths)
170
+ total_est = sum(p["jsonl_files"] for p in info)
171
+ self._serve_json({
172
+ "paths": info,
173
+ "estimated_records": total_est,
174
+ })
175
+
176
+ # ── claude.ai browser tracking GET endpoints ──
177
+ elif path == "/api/claude-ai/accounts":
178
+ conn = get_conn()
179
+ accounts = get_claude_ai_accounts_all(conn)
180
+ # Attach latest snapshot to each
181
+ for a in accounts:
182
+ snap = get_latest_claude_ai_snapshot(conn, a["account_id"])
183
+ a["latest_snapshot"] = snap
184
+ # Never expose session_key in API responses
185
+ a.pop("session_key", None)
186
+ conn.close()
187
+ self._serve_json(accounts)
188
+
189
+ elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/history$", path):
190
+ m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/history$", path)
191
+ account_id = m.group(1)
192
+ conn = get_conn()
193
+ history = get_claude_ai_snapshot_history(conn, account_id, 48)
194
+ conn.close()
195
+ self._serve_json(history)
196
+
197
+ elif path == "/tools/mac-sync.py":
198
+ self._serve_mac_sync()
199
+
200
+ # ── Fix tracker GET endpoints ──
201
+ elif path == "/api/fixes":
202
+ from fix_tracker import all_fixes_with_latest
203
+ conn = get_conn()
204
+ data = all_fixes_with_latest(conn)
205
+ conn.close()
206
+ self._serve_json(data)
207
+
208
+ elif re.match(r"^/api/fixes/(\d+)$", path):
209
+ m = re.match(r"^/api/fixes/(\d+)$", path)
210
+ fix_id = int(m.group(1))
211
+ from fix_tracker import fix_with_latest
212
+ conn = get_conn()
213
+ data = fix_with_latest(conn, fix_id)
214
+ conn.close()
215
+ if data is None:
216
+ self._serve_json({"error": "fix not found"}, 404)
217
+ else:
218
+ self._serve_json(data)
219
+
220
+ elif re.match(r"^/api/fixes/(\d+)/share-card$", path):
221
+ m = re.match(r"^/api/fixes/(\d+)/share-card$", path)
222
+ fix_id = int(m.group(1))
223
+ from db import get_fix, get_latest_fix_measurement
224
+ from fix_tracker import build_share_card
225
+ conn = get_conn()
226
+ fix = get_fix(conn, fix_id)
227
+ latest = get_latest_fix_measurement(conn, fix_id) if fix else None
228
+ conn.close()
229
+ if not fix:
230
+ self._serve_json({"error": "fix not found"}, 404)
231
+ else:
232
+ text = build_share_card(fix, latest)
233
+ body = text.encode("utf-8")
234
+ self.send_response(200)
235
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
236
+ self.send_header("Access-Control-Allow-Origin", self._cors_origin())
237
+ self.send_header("Content-Length", str(len(body)))
238
+ self.end_headers()
239
+ self.wfile.write(body)
240
+
241
+ elif path == "/health":
242
+ conn = get_conn()
243
+ total = get_session_count(conn)
244
+ conn.close()
245
+ last_scan = get_last_scan_time()
246
+ last_scan_iso = datetime.fromtimestamp(last_scan, tz=timezone.utc).isoformat() if last_scan else None
247
+ self._serve_json({
248
+ "status": "ok",
249
+ "version": "1.0.0",
250
+ "uptime_seconds": int(time.time() - _server_start_time),
251
+ "records": total,
252
+ "last_scan": last_scan_iso,
253
+ })
254
+
255
+ else:
256
+ self._serve_404()
257
+
258
+ def do_POST(self):
259
+ parsed = urlparse(self.path)
260
+ path = parsed.path
261
+
262
+ # Body size guard — cap at 100 KB
263
+ if int(self.headers.get("Content-Length", 0) or 0) > 102400:
264
+ self._serve_json({"error": "request too large"}, 413)
265
+ return
266
+
267
+ # /api/claude-ai/sync keeps its existing X-Sync-Token check (for mac-sync.py).
268
+ # All other write endpoints require X-Dashboard-Key.
269
+ if path != "/api/claude-ai/sync" and not self._require_dashboard_key():
270
+ return
271
+
272
+ body = self._read_body()
273
+
274
+ if path == "/api/scan":
275
+ _data_cache.clear()
276
+ rows = scan_all()
277
+ conn = get_conn()
278
+ insights_count = generate_insights(conn)
279
+ conn.close()
280
+ self._serve_json({"status": "ok", "rows_added": rows, "insights_generated": insights_count})
281
+
282
+ elif re.match(r"^/api/insights/(\d+)/dismiss$", path):
283
+ match = re.match(r"^/api/insights/(\d+)/dismiss$", path)
284
+ insight_id = int(match.group(1))
285
+ conn = get_conn()
286
+ dismiss_insight(conn, insight_id)
287
+ conn.close()
288
+ self._serve_json({"status": "ok", "id": insight_id})
289
+
290
+ elif path == "/api/claude-ai/poll":
291
+ count = poll_claude_ai()
292
+ self._serve_json({"status": "ok", "accounts_polled": count})
293
+
294
+ # ── Account management POST endpoints ──
295
+ elif path == "/api/accounts":
296
+ data = body or {}
297
+ conn = get_conn()
298
+ ok, err = create_account(conn, data)
299
+ conn.close()
300
+ if ok:
301
+ self._serve_json({"success": True, "account_id": data.get("account_id")})
302
+ else:
303
+ self._serve_json({"success": False, "error": err}, 400)
304
+
305
+ elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path):
306
+ m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects$", path)
307
+ account_id = m.group(1)
308
+ data = body or {}
309
+ conn = get_conn()
310
+ ok, err = add_account_project(conn, account_id, data.get("project_name", ""), data.get("keywords", []))
311
+ conn.close()
312
+ if ok:
313
+ self._serve_json({"success": True})
314
+ else:
315
+ self._serve_json({"success": False, "error": err}, 400)
316
+
317
+ elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/scan$", path):
318
+ m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/scan$", path)
319
+ account_id = m.group(1)
320
+ rows = scan_all(account_filter=account_id)
321
+ conn = get_conn()
322
+ insights_count = generate_insights(conn)
323
+ conn.close()
324
+ self._serve_json({"status": "ok", "rows_added": rows, "insights_generated": insights_count})
325
+
326
+ elif path == "/api/accounts/discover":
327
+ discovered = discover_claude_paths()
328
+ self._serve_json({"discovered_paths": discovered})
329
+
330
+ # ── claude.ai browser tracking POST endpoints ──
331
+ elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/setup$", path):
332
+ m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/setup$", path)
333
+ account_id = m.group(1)
334
+ data = body or {}
335
+ session_key = data.get("session_key", "").strip()
336
+ if not session_key:
337
+ self._serve_json({"success": False, "error": "session_key is required"}, 400)
338
+ else:
339
+ result = tracker_setup_account(account_id, session_key)
340
+ status = 200 if result.get("success") else 400
341
+ self._serve_json(result, status)
342
+
343
+ elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/refresh$", path):
344
+ m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/refresh$", path)
345
+ account_id = m.group(1)
346
+ conn = get_conn()
347
+ snap = tracker_poll_single(account_id, conn)
348
+ acct = get_claude_ai_account(conn, account_id)
349
+ conn.close()
350
+ # Never expose session_key
351
+ if acct:
352
+ acct.pop("session_key", None)
353
+ self._serve_json({
354
+ "success": snap is not None and "error" not in (snap or {}),
355
+ "account": acct,
356
+ "snapshot": snap if snap and "error" not in snap else None,
357
+ })
358
+
359
+ elif path == "/api/claude-ai/sync":
360
+ self._handle_sync(body or {})
361
+
362
+ # ── Fix tracker POST endpoints ──
363
+ elif path == "/api/fixes":
364
+ data = body or {}
365
+ project = (data.get("project") or "").strip()
366
+ if not project:
367
+ self._serve_json({"success": False, "error": "project is required"}, 400)
368
+ return
369
+ from fix_tracker import record_fix
370
+ conn = get_conn()
371
+ try:
372
+ fix_id, baseline = record_fix(
373
+ conn,
374
+ project,
375
+ data.get("waste_pattern") or "custom",
376
+ (data.get("title") or "").strip(),
377
+ data.get("fix_type") or "other",
378
+ data.get("fix_detail") or "",
379
+ )
380
+ finally:
381
+ conn.close()
382
+ self._serve_json({
383
+ "success": True,
384
+ "fix_id": fix_id,
385
+ "baseline": baseline,
386
+ "message": "Fix recorded. Check back in 7 days to measure improvement.",
387
+ })
388
+
389
+ elif re.match(r"^/api/fixes/(\d+)/measure$", path):
390
+ m = re.match(r"^/api/fixes/(\d+)/measure$", path)
391
+ fix_id = int(m.group(1))
392
+ from fix_tracker import measure_fix
393
+ conn = get_conn()
394
+ try:
395
+ delta, verdict, metrics = measure_fix(conn, fix_id)
396
+ finally:
397
+ conn.close()
398
+ if delta is None:
399
+ self._serve_json({"success": False, "error": "fix not found"}, 404)
400
+ else:
401
+ msg = {
402
+ "improving": "Fix is working. Keep it in place.",
403
+ "worsened": "Fix regressed. Consider reverting or iterating.",
404
+ "neutral": "No statistically meaningful change yet.",
405
+ "insufficient_data": "Not enough sessions since fix — give it more time.",
406
+ }.get(verdict, "Measurement recorded.")
407
+ self._serve_json({
408
+ "success": True,
409
+ "delta": delta,
410
+ "verdict": verdict,
411
+ "message": msg,
412
+ })
413
+
414
+ else:
415
+ self.send_error(404)
416
+
417
+ def do_PUT(self):
418
+ parsed = urlparse(self.path)
419
+ path = parsed.path
420
+
421
+ if int(self.headers.get("Content-Length", 0) or 0) > 102400:
422
+ self._serve_json({"error": "request too large"}, 413)
423
+ return
424
+
425
+ if not self._require_dashboard_key():
426
+ return
427
+
428
+ body = self._read_body()
429
+
430
+ if re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path):
431
+ m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path)
432
+ account_id = m.group(1)
433
+ data = body or {}
434
+ conn = get_conn()
435
+ ok, err = update_account(conn, account_id, data)
436
+ conn.close()
437
+ if ok:
438
+ # Re-scan if data_paths changed
439
+ if "data_paths" in data:
440
+ scan_all(account_filter=account_id)
441
+ self._serve_json({"success": True})
442
+ else:
443
+ self._serve_json({"success": False, "error": err}, 400)
444
+ else:
445
+ self.send_error(404)
446
+
447
+ def do_DELETE(self):
448
+ parsed = urlparse(self.path)
449
+ path = parsed.path
450
+
451
+ if not self._require_dashboard_key():
452
+ return
453
+
454
+ if re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path):
455
+ m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)$", path)
456
+ account_id = m.group(1)
457
+ conn = get_conn()
458
+ ok, err = delete_account(conn, account_id)
459
+ conn.close()
460
+ if ok:
461
+ self._serve_json({"success": True})
462
+ else:
463
+ self._serve_json({"success": False, "error": err}, 400)
464
+
465
+ elif re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects/(.+)$", path):
466
+ m = re.match(r"^/api/accounts/([a-z][a-z0-9_]*)/projects/(.+)$", path)
467
+ account_id = m.group(1)
468
+ project_name = m.group(2)
469
+ conn = get_conn()
470
+ ok, err = remove_account_project(conn, account_id, project_name)
471
+ conn.close()
472
+ if ok:
473
+ self._serve_json({"success": True})
474
+ else:
475
+ self._serve_json({"success": False, "error": err}, 400)
476
+
477
+ elif re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/session$", path):
478
+ m = re.match(r"^/api/claude-ai/accounts/([a-z][a-z0-9_]*)/session$", path)
479
+ account_id = m.group(1)
480
+ conn = get_conn()
481
+ clear_claude_ai_session(conn, account_id)
482
+ conn.close()
483
+ self._serve_json({"success": True})
484
+
485
+ elif re.match(r"^/api/fixes/(\d+)$", path):
486
+ m = re.match(r"^/api/fixes/(\d+)$", path)
487
+ fix_id = int(m.group(1))
488
+ from db import update_fix_status, get_fix
489
+ conn = get_conn()
490
+ try:
491
+ fix = get_fix(conn, fix_id)
492
+ if not fix:
493
+ self._serve_json({"success": False, "error": "fix not found"}, 404)
494
+ return
495
+ update_fix_status(conn, fix_id, "reverted")
496
+ finally:
497
+ conn.close()
498
+ self._serve_json({"success": True})
499
+
500
+ else:
501
+ self.send_error(404)
502
+
503
+ def do_OPTIONS(self):
504
+ self.send_response(200)
505
+ self.send_header("Access-Control-Allow-Origin", self._cors_origin())
506
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
507
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Dashboard-Key, X-Sync-Token")
508
+ self.end_headers()
509
+
510
+ def _read_body(self):
511
+ length = int(self.headers.get("Content-Length", 0))
512
+ if length > 0:
513
+ raw = self.rfile.read(length)
514
+ try:
515
+ return json.loads(raw)
516
+ except (json.JSONDecodeError, UnicodeDecodeError):
517
+ return {}
518
+ return {}
519
+
520
+ def _require_dashboard_key(self):
521
+ """Enforce X-Dashboard-Key header on write endpoints. Returns True on pass;
522
+ on failure writes 401 and returns False."""
523
+ received = self.headers.get("X-Dashboard-Key", "").strip()
524
+ conn = get_conn()
525
+ try:
526
+ stored = get_setting(conn, "dashboard_key")
527
+ finally:
528
+ conn.close()
529
+ if not stored or received != stored.strip():
530
+ self._serve_json({"error": "unauthorized"}, 401)
531
+ return False
532
+ return True
533
+
534
+ def _serve_template(self, filename):
535
+ filename = os.path.basename(filename)
536
+ filepath = os.path.join(TEMPLATE_DIR, filename)
537
+ try:
538
+ with open(filepath, "r") as f:
539
+ content = f.read()
540
+ self.send_response(200)
541
+ self.send_header("Content-Type", "text/html; charset=utf-8")
542
+ self.end_headers()
543
+ self.wfile.write(content.encode("utf-8"))
544
+ except FileNotFoundError:
545
+ self.send_error(500, f"{filename} not found")
546
+
547
+ def _cors_origin(self):
548
+ origin = self.headers.get("Origin", "")
549
+ allowed = {"http://127.0.0.1:8080", "http://localhost:8080"}
550
+ return origin if origin in allowed else "http://127.0.0.1:8080"
551
+
552
+ def _serve_json(self, data, status=200):
553
+ body = json.dumps(data, default=str).encode("utf-8")
554
+ self.send_response(status)
555
+ self.send_header("Content-Type", "application/json")
556
+ self.send_header("Access-Control-Allow-Origin", self._cors_origin())
557
+ self.end_headers()
558
+ self.wfile.write(body)
559
+
560
+ def _serve_404(self):
561
+ html = (
562
+ '<!DOCTYPE html>\n<html>\n<head>\n'
563
+ ' <title>Claudash</title>\n'
564
+ ' <meta http-equiv="refresh" content="5;url=/">\n'
565
+ ' <style>\n'
566
+ ' body { font-family: monospace; padding: 40px;\n'
567
+ ' background: #F5F0E8; color: #1A1916; }\n'
568
+ ' code { background: #E8E0D0; padding: 2px 6px; }\n'
569
+ ' </style>\n'
570
+ '</head>\n<body>\n'
571
+ ' <h2>Claudash</h2>\n'
572
+ ' <p>Page not found. Redirecting to dashboard in 5 seconds...</p>\n'
573
+ ' <p>If this keeps happening: <a href="/">click here</a></p>\n'
574
+ '</body>\n</html>'
575
+ )
576
+ body = html.encode("utf-8")
577
+ self.send_response(404)
578
+ self.send_header("Content-Type", "text/html; charset=utf-8")
579
+ self.send_header("Content-Length", str(len(body)))
580
+ self.end_headers()
581
+ self.wfile.write(body)
582
+
583
+ def _serve_mac_sync(self):
584
+ """Serve tools/mac-sync.py as-is. The sync token is NOT injected — the
585
+ user must retrieve it via `python3 cli.py claude-ai --sync-token` on the
586
+ VPS and paste it into SYNC_TOKEN manually. This removes the token-leak
587
+ vector where any caller could download a pre-filled script."""
588
+ filepath = os.path.join(PROJECT_DIR, "tools", "mac-sync.py")
589
+ try:
590
+ with open(filepath, "rb") as f:
591
+ body = f.read()
592
+ self.send_response(200)
593
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
594
+ self.send_header("Content-Disposition", 'attachment; filename="mac-sync.py"')
595
+ self.send_header("Content-Length", str(len(body)))
596
+ self.end_headers()
597
+ self.wfile.write(body)
598
+ except FileNotFoundError:
599
+ self.send_error(500, "tools/mac-sync.py not found")
600
+
601
+ def _handle_sync(self, data):
602
+ """Handle POST /api/claude-ai/sync from mac-sync.py.
603
+ Trust boundary is the sync token — if it matches, we trust the pushed data."""
604
+ received_token = self.headers.get("X-Sync-Token", "").strip()
605
+ conn = get_conn()
606
+ try:
607
+ stored_token = get_setting(conn, "sync_token")
608
+ if not stored_token or received_token != stored_token.strip():
609
+ self._serve_json({"success": False, "error": "Invalid sync token"}, 403)
610
+ return
611
+
612
+ session_key = data.get("session_key", "").strip()
613
+ org_id = data.get("org_id", "").strip()
614
+ browser = data.get("browser", "")
615
+ account_hint = data.get("account_hint", "")
616
+
617
+ if not session_key:
618
+ self._serve_json({"success": False, "error": "session_key required"}, 400)
619
+ return
620
+
621
+ accounts = get_claude_ai_accounts_all(conn)
622
+ target_id = None
623
+ target_label = ""
624
+
625
+ for a in accounts:
626
+ if a.get("org_id") == org_id and org_id:
627
+ target_id = a["account_id"]
628
+ target_label = a.get("label", target_id)
629
+ break
630
+
631
+ if not target_id and account_hint:
632
+ hint_lower = account_hint.lower()
633
+ for a in accounts:
634
+ label_lower = (a.get("label") or "").lower()
635
+ if any(word in hint_lower for word in label_lower.split() if len(word) > 2):
636
+ target_id = a["account_id"]
637
+ target_label = a.get("label", target_id)
638
+ break
639
+
640
+ if not target_id:
641
+ for a in accounts:
642
+ if a.get("status") == "unconfigured":
643
+ target_id = a["account_id"]
644
+ target_label = a.get("label", target_id)
645
+ break
646
+
647
+ if not target_id and accounts:
648
+ target_id = accounts[0]["account_id"]
649
+ target_label = accounts[0].get("label", target_id)
650
+ print(f"WARNING: no org_id match for {org_id}, falling back to {target_id}", file=sys.stderr)
651
+ print("Check your config.py ACCOUNTS org_id settings", file=sys.stderr)
652
+
653
+ if not target_id:
654
+ self._serve_json({"success": False, "error": "No accounts configured"}, 400)
655
+ return
656
+
657
+ acct_row = conn.execute(
658
+ "SELECT plan FROM accounts WHERE account_id = ?", (target_id,)
659
+ ).fetchone()
660
+ plan = acct_row["plan"] if acct_row else "max"
661
+
662
+ upsert_claude_ai_account(conn, target_id, target_label, org_id, session_key, plan, "active")
663
+ conn.execute(
664
+ "UPDATE claude_ai_accounts SET mac_sync_mode = 1 WHERE account_id = ?",
665
+ (target_id,),
666
+ )
667
+ update_claude_ai_account_status(conn, target_id, "active", None)
668
+ print(f"[sync] Stored session for {target_id} from {browser} ({account_hint})", file=sys.stderr)
669
+
670
+ pct_used = 0
671
+ usage = data.get("usage")
672
+ if usage and isinstance(usage, dict):
673
+ insert_claude_ai_snapshot(conn, target_id, usage)
674
+ pct_used = usage.get("pct_used", 0)
675
+ print(f"[sync] Stored usage snapshot for {target_id}: {pct_used}% used", file=sys.stderr)
676
+
677
+ self._serve_json({
678
+ "success": True,
679
+ "account_label": target_label,
680
+ "matched_account": target_id,
681
+ "pct_used": pct_used,
682
+ "browser": browser,
683
+ })
684
+ finally:
685
+ conn.close()
686
+
687
+ def _get_data(self, account):
688
+ # Check cache first
689
+ cached = _data_cache.get(account)
690
+ if cached and (time.time() - cached[0]) < CACHE_TTL:
691
+ return cached[1]
692
+
693
+ # Run analysis with 10-second timeout
694
+ result_holder = [None]
695
+ error_holder = [None]
696
+
697
+ def run_analysis():
698
+ try:
699
+ conn = get_conn()
700
+ result_holder[0] = self._build_data(conn, account)
701
+ conn.close()
702
+ except Exception as e:
703
+ error_holder[0] = str(e)
704
+
705
+ t = threading.Thread(target=run_analysis)
706
+ t.start()
707
+ t.join(timeout=10)
708
+ if t.is_alive():
709
+ return {"error": "Analysis timeout — DB may be under load"}
710
+ if error_holder[0]:
711
+ return {"error": error_holder[0]}
712
+
713
+ data = result_holder[0]
714
+ _data_cache[account] = (time.time(), data)
715
+ return data
716
+
717
+ def _build_data(self, conn, account):
718
+ data = full_analysis(conn, account)
719
+ data["last_scan"] = get_last_scan_time()
720
+ data["total_rows"] = get_session_count(conn)
721
+ if data["total_rows"] == 0:
722
+ data["first_run"] = True
723
+ data["first_run_message"] = (
724
+ "No sessions found. "
725
+ "Run: python3 cli.py scan\n"
726
+ "Then check that ~/.claude/projects/ contains JSONL files."
727
+ )
728
+ data["db_size_mb"] = get_db_size_mb()
729
+ # claude.ai browser tracking data
730
+ browser_accounts = get_claude_ai_accounts_all(conn)
731
+ browser_data = {}
732
+ for ba in browser_accounts:
733
+ aid = ba["account_id"]
734
+ snap = get_latest_claude_ai_snapshot(conn, aid)
735
+ browser_data[aid] = {
736
+ "status": ba.get("status", "unconfigured"),
737
+ "label": ba.get("label", aid),
738
+ "plan": ba.get("plan", "max"),
739
+ "last_polled": ba.get("last_polled"),
740
+ "snapshot": snap,
741
+ }
742
+ data["claude_ai_browser"] = browser_data
743
+ data["claude_ai"] = {
744
+ "accounts": [dict(r) for r in get_latest_claude_ai_usage(conn)],
745
+ "statuses": get_account_statuses(),
746
+ "last_poll": get_last_poll_time(),
747
+ }
748
+ conn.close()
749
+ return data
750
+
751
+ def log_message(self, format, *args):
752
+ print(f"[server] {args[0]}", file=sys.stderr)
753
+
754
+
755
+ def start_server(port=8080):
756
+ server = ThreadingHTTPServer(("127.0.0.1", port), DashboardHandler)
757
+ print(f"Dashboard: http://127.0.0.1:{port} (localhost only)", flush=True)
758
+ try:
759
+ server.serve_forever()
760
+ except KeyboardInterrupt:
761
+ print("\n[server] Shutting down", file=sys.stderr)
762
+ server.server_close()