repr-cli 0.2.15__py3-none-any.whl → 0.2.17__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.
Files changed (43) hide show
  1. repr/__init__.py +1 -1
  2. repr/api.py +363 -62
  3. repr/auth.py +47 -38
  4. repr/change_synthesis.py +478 -0
  5. repr/cli.py +4103 -267
  6. repr/config.py +119 -11
  7. repr/configure.py +889 -0
  8. repr/cron.py +419 -0
  9. repr/dashboard/__init__.py +9 -0
  10. repr/dashboard/build.py +126 -0
  11. repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
  12. repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
  13. repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
  14. repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
  15. repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
  16. repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
  17. repr/dashboard/dist/favicon.svg +4 -0
  18. repr/dashboard/dist/index.html +14 -0
  19. repr/dashboard/manager.py +234 -0
  20. repr/dashboard/server.py +1298 -0
  21. repr/db.py +980 -0
  22. repr/hooks.py +3 -2
  23. repr/loaders/__init__.py +22 -0
  24. repr/loaders/base.py +156 -0
  25. repr/loaders/claude_code.py +287 -0
  26. repr/loaders/clawdbot.py +313 -0
  27. repr/loaders/gemini_antigravity.py +381 -0
  28. repr/mcp_server.py +1196 -0
  29. repr/models.py +503 -0
  30. repr/openai_analysis.py +25 -0
  31. repr/session_extractor.py +481 -0
  32. repr/storage.py +360 -0
  33. repr/story_synthesis.py +1296 -0
  34. repr/templates.py +68 -4
  35. repr/timeline.py +710 -0
  36. repr/tools.py +17 -8
  37. {repr_cli-0.2.15.dist-info → repr_cli-0.2.17.dist-info}/METADATA +50 -10
  38. repr_cli-0.2.17.dist-info/RECORD +52 -0
  39. {repr_cli-0.2.15.dist-info → repr_cli-0.2.17.dist-info}/WHEEL +1 -1
  40. {repr_cli-0.2.15.dist-info → repr_cli-0.2.17.dist-info}/entry_points.txt +1 -0
  41. repr_cli-0.2.15.dist-info/RECORD +0 -26
  42. {repr_cli-0.2.15.dist-info → repr_cli-0.2.17.dist-info}/licenses/LICENSE +0 -0
  43. {repr_cli-0.2.15.dist-info → repr_cli-0.2.17.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1298 @@
1
+ """
2
+ HTTP server for repr story dashboard.
3
+
4
+ Serves the Vue dashboard from either:
5
+ 1. User-installed dashboard (~/.repr/dashboard/) - downloaded from GitHub
6
+ 2. Bundled dashboard (repr/dashboard/dist/) - ships with CLI
7
+ """
8
+
9
+ import http.server
10
+ import json
11
+ import mimetypes
12
+ import socketserver
13
+ import threading
14
+ from pathlib import Path
15
+
16
+ from .manager import get_dashboard_path, check_for_updates
17
+
18
+ # Dashboard directory - resolved at runtime
19
+ _dashboard_dir: Path | None = None
20
+
21
+
22
+ def _get_dashboard_dir() -> Path:
23
+ """Get the dashboard directory, caching the result."""
24
+ global _dashboard_dir
25
+ if _dashboard_dir is None:
26
+ _dashboard_dir = get_dashboard_path()
27
+ if _dashboard_dir is None:
28
+ raise RuntimeError("No dashboard available")
29
+ return _dashboard_dir
30
+
31
+
32
+ def _get_stories_from_db() -> list[dict]:
33
+ """Get stories from SQLite database."""
34
+ from ..db import get_db
35
+
36
+ db = get_db()
37
+ # Create project mapping
38
+ projects = db.list_projects()
39
+ project_map = {p["id"]: p["name"] for p in projects}
40
+
41
+ stories = db.list_stories(limit=500)
42
+
43
+ result = []
44
+ for story in stories:
45
+ story_dict = story.model_dump()
46
+ # Enrich with repo name
47
+ story_dict["repo_name"] = project_map.get(story_dict.get("project_id"), "unknown")
48
+
49
+ # author_name is already stored in the database, no git operations needed
50
+
51
+ # Convert datetime objects to ISO strings
52
+ for key in ["created_at", "updated_at", "started_at", "ended_at"]:
53
+ if story_dict.get(key):
54
+ story_dict[key] = story_dict[key].isoformat()
55
+ result.append(story_dict)
56
+
57
+ return result
58
+
59
+
60
+ def _get_stats_from_db() -> dict:
61
+ """Get stats from SQLite database."""
62
+ from ..db import get_db
63
+
64
+ db = get_db()
65
+ stats = db.get_stats()
66
+
67
+ return {
68
+ "count": stats["story_count"],
69
+ "last_updated": None,
70
+ "categories": stats["categories"],
71
+ "files": stats["unique_files"],
72
+ "repos": stats["project_count"],
73
+ }
74
+
75
+
76
+ def _get_config() -> dict:
77
+ """Get current configuration."""
78
+ from ..config import load_config
79
+ return load_config()
80
+
81
+
82
+ def _save_config(config: dict) -> dict:
83
+ """Save configuration."""
84
+ from ..config import save_config
85
+ save_config(config)
86
+ return {"success": True}
87
+
88
+
89
+ def _get_git_origin(repo_path: Path) -> str | None:
90
+ """Get git remote origin URL for a repository."""
91
+ import subprocess
92
+ try:
93
+ result = subprocess.run(
94
+ ["git", "-C", str(repo_path), "remote", "get-url", "origin"],
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=5,
98
+ )
99
+ if result.returncode == 0:
100
+ return result.stdout.strip()
101
+ except Exception:
102
+ pass
103
+ return None
104
+
105
+
106
+ def _extract_repo_name_from_origin(origin: str | None) -> str | None:
107
+ """Extract username/repo from git origin URL, stripping .git suffix."""
108
+ if not origin:
109
+ return None
110
+ # Handle SSH format: git@github.com:user/repo.git -> user/repo
111
+ if origin.startswith("git@"):
112
+ parts = origin.split(":")
113
+ if len(parts) == 2:
114
+ path = parts[1]
115
+ return path.removesuffix(".git")
116
+ # Handle HTTPS format: https://github.com/user/repo.git -> user/repo
117
+ elif "://" in origin:
118
+ # Split by / and get last two parts (user/repo)
119
+ parts = origin.rstrip("/").split("/")
120
+ if len(parts) >= 2:
121
+ user_repo = "/".join(parts[-2:])
122
+ return user_repo.removesuffix(".git")
123
+ return None
124
+
125
+
126
+ def _get_tracked_repos() -> list[dict]:
127
+ """Get tracked repositories with status, origin, and project info."""
128
+ from ..config import get_tracked_repos
129
+ from ..db import get_db
130
+
131
+ repos = get_tracked_repos()
132
+ db = get_db()
133
+
134
+ result = []
135
+ for repo in repos:
136
+ repo_info = dict(repo)
137
+ repo_path = Path(repo["path"])
138
+
139
+ # Check if path exists
140
+ repo_info["exists"] = repo_path.exists()
141
+
142
+ # Get git origin URL
143
+ origin = _get_git_origin(repo_path) if repo_info["exists"] else None
144
+ repo_info["origin"] = origin
145
+
146
+ # Extract repo name from origin (without .git)
147
+ origin_name = _extract_repo_name_from_origin(origin)
148
+ repo_info["origin_name"] = origin_name
149
+
150
+ # Get associated project info from database
151
+ project = db.get_project_by_path(repo_path)
152
+ if project:
153
+ repo_info["project"] = {
154
+ "id": project["id"],
155
+ "name": project["name"],
156
+ "last_generated": project.get("last_generated"),
157
+ "last_commit_sha": project.get("last_commit_sha"),
158
+ }
159
+ else:
160
+ repo_info["project"] = None
161
+
162
+ result.append(repo_info)
163
+ return result
164
+
165
+
166
+ def _add_tracked_repo(path: str) -> dict:
167
+ """Add a repository to tracking."""
168
+ from ..config import add_tracked_repo
169
+ repo_path = Path(path).expanduser().resolve()
170
+ if not (repo_path / ".git").exists():
171
+ return {"success": False, "error": "Not a git repository"}
172
+ add_tracked_repo(str(repo_path))
173
+ return {"success": True}
174
+
175
+
176
+ def _remove_tracked_repo(path: str) -> dict:
177
+ """Remove a repository from tracking."""
178
+ from ..config import remove_tracked_repo
179
+ remove_tracked_repo(path)
180
+ return {"success": True}
181
+
182
+
183
+ def _set_repo_paused(path: str, paused: bool) -> dict:
184
+ """Pause or resume a repository."""
185
+ from ..config import set_repo_paused
186
+ set_repo_paused(path, paused)
187
+ return {"success": True}
188
+
189
+
190
+ def _rename_repo_project(path: str, name: str) -> dict:
191
+ """Rename a repository's project."""
192
+ from pathlib import Path as PathlibPath
193
+ from ..db import get_db
194
+
195
+ if not name or not name.strip():
196
+ return {"success": False, "error": "Name cannot be empty"}
197
+
198
+ db = get_db()
199
+ repo_path = PathlibPath(path).expanduser().resolve()
200
+ db.register_project(repo_path, name.strip())
201
+ return {"success": True}
202
+
203
+
204
+ def _get_cron_status() -> dict:
205
+ """Get cron job status."""
206
+ from ..config import load_config
207
+ config = load_config()
208
+ cron_config = config.get("cron", {})
209
+ return {
210
+ "installed": cron_config.get("installed", False),
211
+ "paused": cron_config.get("paused", False),
212
+ "interval_hours": cron_config.get("interval_hours"),
213
+ "min_commits": cron_config.get("min_commits"),
214
+ }
215
+
216
+
217
+ # ============================================================================
218
+ # Auth API helpers
219
+ # ============================================================================
220
+
221
+ # Global state for active login flow (only one at a time per dashboard)
222
+ _active_login_flow: dict | None = None
223
+ _login_flow_lock = threading.Lock()
224
+
225
+
226
+ def _get_auth_status() -> dict:
227
+ """Get current authentication status."""
228
+ from ..config import get_auth, is_authenticated
229
+
230
+ if not is_authenticated():
231
+ return {
232
+ "authenticated": False,
233
+ "user": None,
234
+ }
235
+
236
+ auth = get_auth()
237
+ return {
238
+ "authenticated": True,
239
+ "user": {
240
+ "user_id": auth.get("user_id"),
241
+ "email": auth.get("email"),
242
+ "username": auth.get("username"),
243
+ "authenticated_at": auth.get("authenticated_at"),
244
+ },
245
+ }
246
+
247
+
248
+ def _start_login_flow() -> dict:
249
+ """Start device code login flow."""
250
+ global _active_login_flow
251
+
252
+ import asyncio
253
+ from ..auth import request_device_code, AuthError
254
+
255
+ with _login_flow_lock:
256
+ # Check if already logged in
257
+ from ..config import is_authenticated
258
+ if is_authenticated():
259
+ return {"error": "already_authenticated", "message": "Already logged in"}
260
+
261
+ # Check if flow already active
262
+ if _active_login_flow is not None:
263
+ # Return existing flow info
264
+ return {
265
+ "status": "pending",
266
+ "user_code": _active_login_flow["user_code"],
267
+ "verification_url": _active_login_flow["verification_url"],
268
+ "expires_in": _active_login_flow["expires_in"],
269
+ }
270
+
271
+ try:
272
+ # Request device code
273
+ loop = asyncio.new_event_loop()
274
+ asyncio.set_event_loop(loop)
275
+ try:
276
+ device_code_response = loop.run_until_complete(request_device_code())
277
+ finally:
278
+ loop.close()
279
+
280
+ # Store flow state
281
+ _active_login_flow = {
282
+ "device_code": device_code_response.device_code,
283
+ "user_code": device_code_response.user_code,
284
+ "verification_url": device_code_response.verification_url,
285
+ "expires_in": device_code_response.expires_in,
286
+ "interval": device_code_response.interval,
287
+ }
288
+
289
+ return {
290
+ "status": "pending",
291
+ "user_code": device_code_response.user_code,
292
+ "verification_url": device_code_response.verification_url,
293
+ "expires_in": device_code_response.expires_in,
294
+ }
295
+
296
+ except AuthError as e:
297
+ return {"error": "auth_error", "message": str(e)}
298
+ except Exception as e:
299
+ return {"error": "unknown_error", "message": str(e)}
300
+
301
+
302
+ def _poll_login_status() -> dict:
303
+ """Poll for login completion."""
304
+ global _active_login_flow
305
+
306
+ import httpx
307
+ from ..config import get_api_base, is_authenticated
308
+ from ..auth import save_token, TokenResponse
309
+ from ..telemetry import get_device_id
310
+ import platform
311
+ import socket
312
+
313
+ with _login_flow_lock:
314
+ # Check if already logged in
315
+ if is_authenticated():
316
+ _active_login_flow = None
317
+ return {"status": "completed", "authenticated": True}
318
+
319
+ # Check if flow is active
320
+ if _active_login_flow is None:
321
+ return {"status": "no_flow", "message": "No login flow active"}
322
+
323
+ device_code = _active_login_flow["device_code"]
324
+
325
+ try:
326
+ # Get device name
327
+ hostname = socket.gethostname()
328
+ system = platform.system()
329
+ device_name = f"{hostname} ({system})"
330
+
331
+ # Poll the token endpoint
332
+ token_url = f"{get_api_base()}/token"
333
+ print(f"[DEBUG] Polling token URL: {token_url}")
334
+
335
+ with httpx.Client() as client:
336
+ response = client.post(
337
+ token_url,
338
+ json={
339
+ "device_code": device_code,
340
+ "client_id": "repr-cli",
341
+ "device_id": get_device_id(),
342
+ "device_name": device_name,
343
+ },
344
+ timeout=30,
345
+ )
346
+
347
+ if response.status_code == 200:
348
+ data = response.json()
349
+ user_data = data.get("user", {})
350
+ token = TokenResponse(
351
+ access_token=data["access_token"],
352
+ user_id=user_data.get("id", ""),
353
+ email=user_data.get("email", ""),
354
+ username=user_data.get("username"),
355
+ litellm_api_key=data.get("litellm_api_key"),
356
+ )
357
+ save_token(token)
358
+
359
+ # Clear flow
360
+ _active_login_flow = None
361
+
362
+ return {
363
+ "status": "completed",
364
+ "authenticated": True,
365
+ "user": {
366
+ "user_id": token.user_id,
367
+ "email": token.email,
368
+ "username": token.username,
369
+ },
370
+ }
371
+
372
+ if response.status_code == 400:
373
+ data = response.json()
374
+ error = data.get("error", "unknown")
375
+
376
+ if error == "authorization_pending":
377
+ return {"status": "pending", "message": "Waiting for authorization"}
378
+ elif error == "slow_down":
379
+ return {"status": "pending", "message": "Slow down, polling too fast"}
380
+ elif error == "expired_token":
381
+ _active_login_flow = None
382
+ return {"status": "expired", "message": "Device code expired"}
383
+ elif error == "access_denied":
384
+ _active_login_flow = None
385
+ return {"status": "denied", "message": "Authorization denied"}
386
+ else:
387
+ _active_login_flow = None
388
+ return {"status": "error", "message": f"Authorization failed: {error}"}
389
+
390
+ return {"status": "error", "message": f"Unexpected status: {response.status_code}"}
391
+
392
+ except httpx.RequestError as e:
393
+ return {"status": "error", "message": f"Network error: {str(e)}"}
394
+ except Exception as e:
395
+ return {"status": "error", "message": str(e)}
396
+
397
+
398
+ def _cancel_login_flow() -> dict:
399
+ """Cancel active login flow."""
400
+ global _active_login_flow
401
+
402
+ with _login_flow_lock:
403
+ if _active_login_flow is None:
404
+ return {"success": True, "message": "No active flow"}
405
+
406
+ _active_login_flow = None
407
+ return {"success": True, "message": "Login flow cancelled"}
408
+
409
+
410
+ def _logout() -> dict:
411
+ """Logout current user."""
412
+ global _active_login_flow
413
+
414
+ from ..auth import logout
415
+ from ..config import is_authenticated
416
+
417
+ with _login_flow_lock:
418
+ _active_login_flow = None
419
+
420
+ if not is_authenticated():
421
+ return {"success": True, "message": "Already logged out"}
422
+
423
+ logout()
424
+ return {"success": True, "message": "Logged out successfully"}
425
+
426
+
427
+ def _save_auth_token(token_data: dict) -> dict:
428
+ """Save auth token received from frontend direct auth with api.repr.dev."""
429
+ from ..auth import save_token, TokenResponse
430
+
431
+ try:
432
+ user = token_data.get("user", {})
433
+ token = TokenResponse(
434
+ access_token=token_data["access_token"],
435
+ user_id=user.get("id", ""),
436
+ email=user.get("email", ""),
437
+ username=user.get("username"),
438
+ litellm_api_key=token_data.get("litellm_api_key"),
439
+ )
440
+ save_token(token)
441
+ return {"success": True, "message": "Token saved successfully"}
442
+ except Exception as e:
443
+ return {"success": False, "error": str(e)}
444
+
445
+
446
+ # ============================================================================
447
+ # Username API helpers
448
+ # ============================================================================
449
+
450
+ def _get_username_info() -> dict:
451
+ """Get current username info (local + remote)."""
452
+ from ..config import get_auth, get_profile_config, is_authenticated
453
+
454
+ profile = get_profile_config()
455
+ local_username = profile.get("username")
456
+ claimed = profile.get("claimed", False)
457
+
458
+ result = {
459
+ "local_username": local_username,
460
+ "claimed": claimed,
461
+ "remote_username": None,
462
+ }
463
+
464
+ # If authenticated, get remote username
465
+ if is_authenticated():
466
+ auth = get_auth()
467
+ result["remote_username"] = auth.get("username")
468
+ result["user_id"] = auth.get("user_id")
469
+ result["email"] = auth.get("email")
470
+
471
+ return result
472
+
473
+
474
+ def _set_local_username(username: str) -> dict:
475
+ """Set local username (without claiming on server)."""
476
+ from ..config import set_profile_config
477
+
478
+ if not username or not username.strip():
479
+ return {"success": False, "error": "Username cannot be empty"}
480
+
481
+ username = username.strip().lower()
482
+
483
+ # Basic validation
484
+ if len(username) < 3:
485
+ return {"success": False, "error": "Username must be at least 3 characters"}
486
+ if len(username) > 30:
487
+ return {"success": False, "error": "Username must be at most 30 characters"}
488
+
489
+ set_profile_config(username=username, claimed=False)
490
+ return {"success": True, "username": username}
491
+
492
+
493
+ def _check_username_availability(username: str) -> dict:
494
+ """Check if username is available on the server."""
495
+ import httpx
496
+ from ..config import get_api_base
497
+
498
+ if not username or not username.strip():
499
+ return {"available": False, "reason": "Username cannot be empty"}
500
+
501
+ username = username.strip().lower()
502
+
503
+ try:
504
+ url = f"{get_api_base()}/username/check/{username}"
505
+ with httpx.Client() as client:
506
+ response = client.get(url, timeout=30)
507
+ if response.status_code == 200:
508
+ return response.json()
509
+ return {"available": False, "reason": f"Server error: {response.status_code}"}
510
+ except httpx.RequestError as e:
511
+ return {"available": False, "reason": f"Network error: {str(e)}"}
512
+ except Exception as e:
513
+ return {"available": False, "reason": str(e)}
514
+
515
+
516
+ def _claim_username(username: str) -> dict:
517
+ """Claim username on the server."""
518
+ import httpx
519
+ from ..config import get_api_base, get_access_token, set_profile_config, is_authenticated
520
+
521
+ if not is_authenticated():
522
+ return {"success": False, "error": "Not authenticated. Please login first."}
523
+
524
+ if not username or not username.strip():
525
+ return {"success": False, "error": "Username cannot be empty"}
526
+
527
+ username = username.strip().lower()
528
+ token = get_access_token()
529
+
530
+ try:
531
+ url = f"{get_api_base()}/username/claim"
532
+ with httpx.Client() as client:
533
+ response = client.post(
534
+ url,
535
+ json={"username": username},
536
+ headers={"Authorization": f"Bearer {token}"},
537
+ timeout=30,
538
+ )
539
+ if response.status_code == 200:
540
+ data = response.json()
541
+ if data.get("success"):
542
+ # Update local config
543
+ set_profile_config(username=username, claimed=True)
544
+ return data
545
+ return {"success": False, "error": f"Server error: {response.status_code}"}
546
+ except httpx.RequestError as e:
547
+ return {"success": False, "error": f"Network error: {str(e)}"}
548
+ except Exception as e:
549
+ return {"success": False, "error": str(e)}
550
+
551
+
552
+ # ============================================================================
553
+ # Visibility API helpers
554
+ # ============================================================================
555
+
556
+ def _get_visibility_settings() -> dict:
557
+ """Get visibility settings from config, with backend fetch if authenticated."""
558
+ from ..config import load_config, is_authenticated, get_access_token, get_api_base
559
+ import httpx
560
+
561
+ config = load_config()
562
+ privacy = config.get("privacy", {})
563
+
564
+ # Local defaults (all private by default)
565
+ local_settings = {
566
+ "profile": privacy.get("profile_visibility", "private"),
567
+ "repos_default": privacy.get("repos_default_visibility", "private"),
568
+ "stories_default": privacy.get("stories_default_visibility", "private"),
569
+ }
570
+
571
+ # Try to fetch from backend if authenticated
572
+ if is_authenticated():
573
+ try:
574
+ token = get_access_token()
575
+ url = f"{get_api_base()}/visibility"
576
+ with httpx.Client() as client:
577
+ response = client.get(
578
+ url,
579
+ headers={"Authorization": f"Bearer {token}"},
580
+ timeout=10,
581
+ )
582
+ if response.status_code == 200:
583
+ backend_settings = response.json()
584
+ # Merge backend settings (they take precedence)
585
+ return {
586
+ "profile": backend_settings.get("profile", local_settings["profile"]),
587
+ "repos_default": backend_settings.get("repos_default", local_settings["repos_default"]),
588
+ "stories_default": backend_settings.get("stories_default", local_settings["stories_default"]),
589
+ }
590
+ except Exception:
591
+ pass # Fall back to local settings
592
+
593
+ return local_settings
594
+
595
+
596
+ def _set_visibility_settings(settings: dict) -> dict:
597
+ """Set visibility settings in config and sync to backend if authenticated."""
598
+ from ..config import load_config, save_config, is_authenticated, get_access_token, get_api_base
599
+ import httpx
600
+
601
+ config = load_config()
602
+ if "privacy" not in config:
603
+ config["privacy"] = {}
604
+
605
+ valid_values = {"public", "private", "connections"}
606
+ update_data = {}
607
+
608
+ if "profile" in settings and settings["profile"] in valid_values:
609
+ config["privacy"]["profile_visibility"] = settings["profile"]
610
+ update_data["profile"] = settings["profile"]
611
+ if "repos_default" in settings and settings["repos_default"] in valid_values:
612
+ config["privacy"]["repos_default_visibility"] = settings["repos_default"]
613
+ update_data["repos_default"] = settings["repos_default"]
614
+ if "stories_default" in settings and settings["stories_default"] in valid_values:
615
+ config["privacy"]["stories_default_visibility"] = settings["stories_default"]
616
+ update_data["stories_default"] = settings["stories_default"]
617
+
618
+ save_config(config)
619
+
620
+ # Sync to backend if authenticated
621
+ backend_synced = False
622
+ if is_authenticated() and update_data:
623
+ try:
624
+ token = get_access_token()
625
+ url = f"{get_api_base()}/visibility"
626
+ with httpx.Client() as client:
627
+ response = client.patch(
628
+ url,
629
+ json=update_data,
630
+ headers={"Authorization": f"Bearer {token}"},
631
+ timeout=30,
632
+ )
633
+ backend_synced = response.status_code == 200
634
+ except Exception:
635
+ pass # Fail silently, local config is still saved
636
+
637
+ return {"success": True, "backend_synced": backend_synced}
638
+
639
+
640
+ def _set_story_visibility(story_id: str, visibility: str) -> dict:
641
+ """Set visibility for a specific story."""
642
+ from ..db import get_db
643
+
644
+ valid_values = {"public", "private", "connections"}
645
+ if visibility not in valid_values:
646
+ return {"success": False, "error": f"Invalid visibility: {visibility}"}
647
+
648
+ db = get_db()
649
+ story = db.get_story(story_id)
650
+ if not story:
651
+ return {"success": False, "error": "Story not found"}
652
+
653
+ # Update the story visibility
654
+ db.update_story_visibility(story_id, visibility)
655
+ return {"success": True, "story_id": story_id, "visibility": visibility}
656
+
657
+
658
+ class TimelineHandler(http.server.BaseHTTPRequestHandler):
659
+ """HTTP handler for story dashboard."""
660
+
661
+ def log_message(self, format: str, *args) -> None:
662
+ pass
663
+
664
+ def do_GET(self):
665
+ if self.path.startswith("/api/"):
666
+ if self.path == "/api/stories":
667
+ self.serve_stories()
668
+ elif self.path.startswith("/api/diff"):
669
+ self.serve_diff()
670
+ elif self.path == "/api/status":
671
+ self.serve_status()
672
+ elif self.path == "/api/config":
673
+ self.serve_config()
674
+ elif self.path == "/api/repos":
675
+ self.serve_repos()
676
+ elif self.path == "/api/cron":
677
+ self.serve_cron()
678
+ elif self.path == "/api/auth":
679
+ self.serve_auth_status()
680
+ elif self.path == "/api/auth/login/status":
681
+ self.serve_login_poll()
682
+ elif self.path == "/api/username":
683
+ self.serve_username_info()
684
+ elif self.path.startswith("/api/username/check/"):
685
+ self.check_username()
686
+ elif self.path == "/api/visibility":
687
+ self.serve_visibility_settings()
688
+ else:
689
+ self.send_error(404, "API Endpoint Not Found")
690
+ elif "." in self.path.split("/")[-1]:
691
+ # Serve static files if path looks like a file
692
+ self.serve_static()
693
+ else:
694
+ # SPA fallback - serve index.html for all other routes
695
+ self.serve_dashboard()
696
+
697
+ def do_PUT(self):
698
+ if self.path == "/api/config":
699
+ self.update_config()
700
+ else:
701
+ self.send_error(404, "API Endpoint Not Found")
702
+
703
+ def do_POST(self):
704
+ if self.path == "/api/repos/add":
705
+ self.add_repo()
706
+ elif self.path == "/api/repos/remove":
707
+ self.remove_repo()
708
+ elif self.path == "/api/repos/pause":
709
+ self.pause_repo()
710
+ elif self.path == "/api/repos/resume":
711
+ self.resume_repo()
712
+ elif self.path == "/api/repos/rename":
713
+ self.rename_repo()
714
+ elif self.path == "/api/generate":
715
+ self.trigger_generation()
716
+ elif self.path == "/api/auth/login":
717
+ self.start_login()
718
+ elif self.path == "/api/auth/login/cancel":
719
+ self.cancel_login()
720
+ elif self.path == "/api/auth/save":
721
+ self.save_auth_token()
722
+ elif self.path == "/api/auth/logout":
723
+ self.do_logout()
724
+ elif self.path == "/api/username/set":
725
+ self.set_username()
726
+ elif self.path == "/api/username/claim":
727
+ self.claim_username()
728
+ elif self.path == "/api/visibility":
729
+ self.update_visibility_settings()
730
+ elif self.path.startswith("/api/stories/") and self.path.endswith("/visibility"):
731
+ self.update_story_visibility()
732
+ else:
733
+ self.send_error(404, "API Endpoint Not Found")
734
+
735
+ def do_OPTIONS(self):
736
+ """Handle CORS preflight."""
737
+ self.send_response(200)
738
+ self.send_header("Access-Control-Allow-Origin", "*")
739
+ self.send_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS")
740
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
741
+ self.end_headers()
742
+
743
+ def serve_dashboard(self):
744
+ try:
745
+ dashboard_dir = _get_dashboard_dir()
746
+ index_path = dashboard_dir / "index.html"
747
+
748
+ if index_path.exists():
749
+ content = index_path.read_text(encoding="utf-8")
750
+ self.send_response(200)
751
+ self.send_header("Content-Type", "text/html; charset=utf-8")
752
+ self.send_header("Content-Length", len(content.encode("utf-8")))
753
+ self.end_headers()
754
+ self.wfile.write(content.encode("utf-8"))
755
+ else:
756
+ self.send_error(404, f"Dashboard index.html not found at {index_path}")
757
+ except Exception as e:
758
+ self.send_error(500, str(e))
759
+
760
+ def serve_static(self):
761
+ """Serve static files from dashboard directory."""
762
+ try:
763
+ dashboard_dir = _get_dashboard_dir()
764
+ clean_path = self.path.lstrip("/")
765
+ file_path = (dashboard_dir / clean_path).resolve()
766
+
767
+ # Security check: ensure path is within dashboard dir
768
+ if not str(file_path).startswith(str(dashboard_dir.resolve())):
769
+ self.send_error(403, "Access denied")
770
+ return
771
+
772
+ # Block sensitive files
773
+ if file_path.suffix == ".py" or file_path.name.startswith("."):
774
+ self.send_error(403, "Access denied")
775
+ return
776
+
777
+ if not file_path.exists() or not file_path.is_file():
778
+ self.send_error(404, "File not found")
779
+ return
780
+
781
+ content_type, _ = mimetypes.guess_type(file_path)
782
+ content_type = content_type or "application/octet-stream"
783
+
784
+ content = file_path.read_bytes()
785
+ self.send_response(200)
786
+ self.send_header("Content-Type", content_type)
787
+ self.send_header("Cache-Control", "max-age=31536000, immutable") # Cache static assets
788
+ self.send_header("Content-Length", len(content))
789
+ self.end_headers()
790
+ self.wfile.write(content)
791
+ except Exception as e:
792
+ self.send_error(500, str(e))
793
+
794
+ def serve_stories(self):
795
+ try:
796
+ stories = _get_stories_from_db()
797
+ response = {"stories": stories}
798
+ body = json.dumps(response)
799
+ self.send_response(200)
800
+ self.send_header("Content-Type", "application/json")
801
+ self.send_header("Access-Control-Allow-Origin", "*")
802
+ self.send_header("Content-Length", len(body.encode()))
803
+ self.end_headers()
804
+ self.wfile.write(body.encode())
805
+ except Exception as e:
806
+ self.send_error(500, str(e))
807
+
808
+ def serve_diff(self):
809
+ """Serve diff for a story."""
810
+ from urllib.parse import urlparse, parse_qs
811
+ from ..db import get_db
812
+ from ..tools import get_commits_by_shas
813
+
814
+ try:
815
+ query = parse_qs(urlparse(self.path).query)
816
+ story_id = query.get("story_id", [None])[0]
817
+
818
+ if not story_id:
819
+ self.send_error(400, "Missing story_id")
820
+ return
821
+
822
+ db = get_db()
823
+ story = db.get_story(story_id)
824
+ if not story:
825
+ self.send_error(404, "Story not found")
826
+ return
827
+
828
+ project = db.get_project_by_id(story.project_id)
829
+ if not project:
830
+ self.send_error(404, "Project not found")
831
+ return
832
+
833
+ project_path = Path(project["path"])
834
+ if not project_path.exists():
835
+ self.send_error(404, "Repository path not found")
836
+ return
837
+
838
+ commit_shas = story.commit_shas
839
+ commits = get_commits_by_shas(project_path, commit_shas)
840
+
841
+ body = json.dumps({"commits": commits}, default=str)
842
+ self.send_response(200)
843
+ self.send_header("Content-Type", "application/json")
844
+ self.send_header("Access-Control-Allow-Origin", "*")
845
+ self.send_header("Content-Length", len(body.encode()))
846
+ self.end_headers()
847
+ self.wfile.write(body.encode())
848
+ except Exception as e:
849
+ self.send_error(500, str(e))
850
+
851
+ def serve_status(self):
852
+ try:
853
+ stats = _get_stats_from_db()
854
+ body = json.dumps(stats)
855
+ self.send_response(200)
856
+ self.send_header("Content-Type", "application/json")
857
+ self.send_header("Content-Length", len(body.encode()))
858
+ self.end_headers()
859
+ self.wfile.write(body.encode())
860
+ except Exception:
861
+ self.send_error(500, "Error loading stats")
862
+
863
+ def serve_config(self):
864
+ """Serve current configuration."""
865
+ try:
866
+ config = _get_config()
867
+ body = json.dumps(config)
868
+ self.send_response(200)
869
+ self.send_header("Content-Type", "application/json")
870
+ self.send_header("Access-Control-Allow-Origin", "*")
871
+ self.send_header("Content-Length", len(body.encode()))
872
+ self.end_headers()
873
+ self.wfile.write(body.encode())
874
+ except Exception as e:
875
+ self.send_error(500, str(e))
876
+
877
+ def update_config(self):
878
+ """Update configuration."""
879
+ try:
880
+ content_length = int(self.headers.get("Content-Length", 0))
881
+ body = self.rfile.read(content_length)
882
+ new_config = json.loads(body.decode())
883
+ result = _save_config(new_config)
884
+ response = json.dumps(result)
885
+ self.send_response(200)
886
+ self.send_header("Content-Type", "application/json")
887
+ self.send_header("Access-Control-Allow-Origin", "*")
888
+ self.send_header("Content-Length", len(response.encode()))
889
+ self.end_headers()
890
+ self.wfile.write(response.encode())
891
+ except json.JSONDecodeError:
892
+ self.send_error(400, "Invalid JSON")
893
+ except Exception as e:
894
+ self.send_error(500, str(e))
895
+
896
+ def serve_repos(self):
897
+ """Serve tracked repositories."""
898
+ try:
899
+ repos = _get_tracked_repos()
900
+ body = json.dumps({"repos": repos})
901
+ self.send_response(200)
902
+ self.send_header("Content-Type", "application/json")
903
+ self.send_header("Access-Control-Allow-Origin", "*")
904
+ self.send_header("Content-Length", len(body.encode()))
905
+ self.end_headers()
906
+ self.wfile.write(body.encode())
907
+ except Exception as e:
908
+ self.send_error(500, str(e))
909
+
910
+ def add_repo(self):
911
+ """Add a repository to tracking."""
912
+ try:
913
+ content_length = int(self.headers.get("Content-Length", 0))
914
+ body = self.rfile.read(content_length)
915
+ data = json.loads(body.decode())
916
+ result = _add_tracked_repo(data.get("path", ""))
917
+ response = json.dumps(result)
918
+ self.send_response(200)
919
+ self.send_header("Content-Type", "application/json")
920
+ self.send_header("Access-Control-Allow-Origin", "*")
921
+ self.send_header("Content-Length", len(response.encode()))
922
+ self.end_headers()
923
+ self.wfile.write(response.encode())
924
+ except Exception as e:
925
+ self.send_error(500, str(e))
926
+
927
+ def remove_repo(self):
928
+ """Remove a repository from tracking."""
929
+ try:
930
+ content_length = int(self.headers.get("Content-Length", 0))
931
+ body = self.rfile.read(content_length)
932
+ data = json.loads(body.decode())
933
+ result = _remove_tracked_repo(data.get("path", ""))
934
+ response = json.dumps(result)
935
+ self.send_response(200)
936
+ self.send_header("Content-Type", "application/json")
937
+ self.send_header("Access-Control-Allow-Origin", "*")
938
+ self.send_header("Content-Length", len(response.encode()))
939
+ self.end_headers()
940
+ self.wfile.write(response.encode())
941
+ except Exception as e:
942
+ self.send_error(500, str(e))
943
+
944
+ def pause_repo(self):
945
+ """Pause a repository."""
946
+ try:
947
+ content_length = int(self.headers.get("Content-Length", 0))
948
+ body = self.rfile.read(content_length)
949
+ data = json.loads(body.decode())
950
+ result = _set_repo_paused(data.get("path", ""), True)
951
+ response = json.dumps(result)
952
+ self.send_response(200)
953
+ self.send_header("Content-Type", "application/json")
954
+ self.send_header("Access-Control-Allow-Origin", "*")
955
+ self.send_header("Content-Length", len(response.encode()))
956
+ self.end_headers()
957
+ self.wfile.write(response.encode())
958
+ except Exception as e:
959
+ self.send_error(500, str(e))
960
+
961
+ def resume_repo(self):
962
+ """Resume a repository."""
963
+ try:
964
+ content_length = int(self.headers.get("Content-Length", 0))
965
+ body = self.rfile.read(content_length)
966
+ data = json.loads(body.decode())
967
+ result = _set_repo_paused(data.get("path", ""), False)
968
+ response = json.dumps(result)
969
+ self.send_response(200)
970
+ self.send_header("Content-Type", "application/json")
971
+ self.send_header("Access-Control-Allow-Origin", "*")
972
+ self.send_header("Content-Length", len(response.encode()))
973
+ self.end_headers()
974
+ self.wfile.write(response.encode())
975
+ except Exception as e:
976
+ self.send_error(500, str(e))
977
+
978
+ def rename_repo(self):
979
+ """Rename a repository's project."""
980
+ try:
981
+ content_length = int(self.headers.get("Content-Length", 0))
982
+ body = self.rfile.read(content_length)
983
+ data = json.loads(body.decode())
984
+ result = _rename_repo_project(data.get("path", ""), data.get("name", ""))
985
+ response = json.dumps(result)
986
+ self.send_response(200)
987
+ self.send_header("Content-Type", "application/json")
988
+ self.send_header("Access-Control-Allow-Origin", "*")
989
+ self.send_header("Content-Length", len(response.encode()))
990
+ self.end_headers()
991
+ self.wfile.write(response.encode())
992
+ except Exception as e:
993
+ self.send_error(500, str(e))
994
+
995
+ def serve_cron(self):
996
+ """Serve cron status."""
997
+ try:
998
+ status = _get_cron_status()
999
+ body = json.dumps(status)
1000
+ self.send_response(200)
1001
+ self.send_header("Content-Type", "application/json")
1002
+ self.send_header("Access-Control-Allow-Origin", "*")
1003
+ self.send_header("Content-Length", len(body.encode()))
1004
+ self.end_headers()
1005
+ self.wfile.write(body.encode())
1006
+ except Exception as e:
1007
+ self.send_error(500, str(e))
1008
+
1009
+ def trigger_generation(self):
1010
+ """Trigger story generation background process."""
1011
+ import subprocess
1012
+ import sys
1013
+
1014
+ try:
1015
+ # We use Popen to run it in background so we can return immediately
1016
+ # Using same python executable
1017
+ cmd = [sys.executable, "-m", "repr", "generate"]
1018
+
1019
+ # Check config for cloud mode preferences, but default to safe (local) if unsure
1020
+ config = _get_config()
1021
+ # If default mode is cloud AND user is allowed, we could add --cloud
1022
+ # But safer to just let CLI logic handle defaults (it defaults to local if not auth)
1023
+ # Maybe add --batch-size from config?
1024
+ if config.get("generation", {}).get("batch_size"):
1025
+ cmd.extend(["--batch-size", str(config["generation"]["batch_size"])])
1026
+
1027
+ subprocess.Popen(
1028
+ cmd,
1029
+ stdout=subprocess.DEVNULL,
1030
+ stderr=subprocess.DEVNULL,
1031
+ start_new_session=True
1032
+ )
1033
+
1034
+ response = json.dumps({"success": True, "message": "Generation started in background"})
1035
+ self.send_response(200)
1036
+ self.send_header("Content-Type", "application/json")
1037
+ self.send_header("Access-Control-Allow-Origin", "*")
1038
+ self.send_header("Content-Length", len(response.encode()))
1039
+ self.end_headers()
1040
+ self.wfile.write(response.encode())
1041
+ except Exception as e:
1042
+ self.send_error(500, str(e))
1043
+
1044
+ # =========================================================================
1045
+ # Auth endpoints
1046
+ # =========================================================================
1047
+
1048
+ def serve_auth_status(self):
1049
+ """Serve current authentication status."""
1050
+ try:
1051
+ status = _get_auth_status()
1052
+ body = json.dumps(status)
1053
+ self.send_response(200)
1054
+ self.send_header("Content-Type", "application/json")
1055
+ self.send_header("Access-Control-Allow-Origin", "*")
1056
+ self.send_header("Content-Length", len(body.encode()))
1057
+ self.end_headers()
1058
+ self.wfile.write(body.encode())
1059
+ except Exception as e:
1060
+ self.send_error(500, str(e))
1061
+
1062
+ def start_login(self):
1063
+ """Start device code login flow."""
1064
+ try:
1065
+ result = _start_login_flow()
1066
+ body = json.dumps(result)
1067
+ self.send_response(200)
1068
+ self.send_header("Content-Type", "application/json")
1069
+ self.send_header("Access-Control-Allow-Origin", "*")
1070
+ self.send_header("Content-Length", len(body.encode()))
1071
+ self.end_headers()
1072
+ self.wfile.write(body.encode())
1073
+ except Exception as e:
1074
+ self.send_error(500, str(e))
1075
+
1076
+ def serve_login_poll(self):
1077
+ """Poll for login completion."""
1078
+ try:
1079
+ result = _poll_login_status()
1080
+ body = json.dumps(result)
1081
+ self.send_response(200)
1082
+ self.send_header("Content-Type", "application/json")
1083
+ self.send_header("Access-Control-Allow-Origin", "*")
1084
+ self.send_header("Content-Length", len(body.encode()))
1085
+ self.end_headers()
1086
+ self.wfile.write(body.encode())
1087
+ except Exception as e:
1088
+ self.send_error(500, str(e))
1089
+
1090
+ def cancel_login(self):
1091
+ """Cancel active login flow."""
1092
+ try:
1093
+ result = _cancel_login_flow()
1094
+ body = json.dumps(result)
1095
+ self.send_response(200)
1096
+ self.send_header("Content-Type", "application/json")
1097
+ self.send_header("Access-Control-Allow-Origin", "*")
1098
+ self.send_header("Content-Length", len(body.encode()))
1099
+ self.end_headers()
1100
+ self.wfile.write(body.encode())
1101
+ except Exception as e:
1102
+ self.send_error(500, str(e))
1103
+
1104
+ def save_auth_token(self):
1105
+ """Save auth token from frontend direct auth."""
1106
+ try:
1107
+ content_length = int(self.headers.get("Content-Length", 0))
1108
+ body = self.rfile.read(content_length)
1109
+ data = json.loads(body) if body else {}
1110
+
1111
+ result = _save_auth_token(data)
1112
+ response_body = json.dumps(result)
1113
+ self.send_response(200)
1114
+ self.send_header("Content-Type", "application/json")
1115
+ self.send_header("Access-Control-Allow-Origin", "*")
1116
+ self.send_header("Content-Length", len(response_body.encode()))
1117
+ self.end_headers()
1118
+ self.wfile.write(response_body.encode())
1119
+ except Exception as e:
1120
+ self.send_error(500, str(e))
1121
+
1122
+ def do_logout(self):
1123
+ """Logout current user."""
1124
+ try:
1125
+ result = _logout()
1126
+ body = json.dumps(result)
1127
+ self.send_response(200)
1128
+ self.send_header("Content-Type", "application/json")
1129
+ self.send_header("Access-Control-Allow-Origin", "*")
1130
+ self.send_header("Content-Length", len(body.encode()))
1131
+ self.end_headers()
1132
+ self.wfile.write(body.encode())
1133
+ except Exception as e:
1134
+ self.send_error(500, str(e))
1135
+
1136
+ # =========================================================================
1137
+ # Username endpoints
1138
+ # =========================================================================
1139
+
1140
+ def serve_username_info(self):
1141
+ """Serve current username info."""
1142
+ try:
1143
+ info = _get_username_info()
1144
+ body = json.dumps(info)
1145
+ self.send_response(200)
1146
+ self.send_header("Content-Type", "application/json")
1147
+ self.send_header("Access-Control-Allow-Origin", "*")
1148
+ self.send_header("Content-Length", len(body.encode()))
1149
+ self.end_headers()
1150
+ self.wfile.write(body.encode())
1151
+ except Exception as e:
1152
+ self.send_error(500, str(e))
1153
+
1154
+ def check_username(self):
1155
+ """Check username availability."""
1156
+ try:
1157
+ # Extract username from path: /api/username/check/{username}
1158
+ parts = self.path.split("/")
1159
+ username = parts[-1] if len(parts) > 4 else ""
1160
+
1161
+ result = _check_username_availability(username)
1162
+ body = json.dumps(result)
1163
+ self.send_response(200)
1164
+ self.send_header("Content-Type", "application/json")
1165
+ self.send_header("Access-Control-Allow-Origin", "*")
1166
+ self.send_header("Content-Length", len(body.encode()))
1167
+ self.end_headers()
1168
+ self.wfile.write(body.encode())
1169
+ except Exception as e:
1170
+ self.send_error(500, str(e))
1171
+
1172
+ def set_username(self):
1173
+ """Set local username."""
1174
+ try:
1175
+ content_length = int(self.headers.get("Content-Length", 0))
1176
+ body = self.rfile.read(content_length)
1177
+ data = json.loads(body.decode())
1178
+ result = _set_local_username(data.get("username", ""))
1179
+ response = json.dumps(result)
1180
+ self.send_response(200)
1181
+ self.send_header("Content-Type", "application/json")
1182
+ self.send_header("Access-Control-Allow-Origin", "*")
1183
+ self.send_header("Content-Length", len(response.encode()))
1184
+ self.end_headers()
1185
+ self.wfile.write(response.encode())
1186
+ except json.JSONDecodeError:
1187
+ self.send_error(400, "Invalid JSON")
1188
+ except Exception as e:
1189
+ self.send_error(500, str(e))
1190
+
1191
+ def claim_username(self):
1192
+ """Claim username on server."""
1193
+ try:
1194
+ content_length = int(self.headers.get("Content-Length", 0))
1195
+ body = self.rfile.read(content_length)
1196
+ data = json.loads(body.decode())
1197
+ result = _claim_username(data.get("username", ""))
1198
+ response = json.dumps(result)
1199
+ self.send_response(200)
1200
+ self.send_header("Content-Type", "application/json")
1201
+ self.send_header("Access-Control-Allow-Origin", "*")
1202
+ self.send_header("Content-Length", len(response.encode()))
1203
+ self.end_headers()
1204
+ self.wfile.write(response.encode())
1205
+ except json.JSONDecodeError:
1206
+ self.send_error(400, "Invalid JSON")
1207
+ except Exception as e:
1208
+ self.send_error(500, str(e))
1209
+
1210
+ # =========================================================================
1211
+ # Visibility endpoints
1212
+ # =========================================================================
1213
+
1214
+ def serve_visibility_settings(self):
1215
+ """Serve visibility settings."""
1216
+ try:
1217
+ settings = _get_visibility_settings()
1218
+ body = json.dumps(settings)
1219
+ self.send_response(200)
1220
+ self.send_header("Content-Type", "application/json")
1221
+ self.send_header("Access-Control-Allow-Origin", "*")
1222
+ self.send_header("Content-Length", len(body.encode()))
1223
+ self.end_headers()
1224
+ self.wfile.write(body.encode())
1225
+ except Exception as e:
1226
+ self.send_error(500, str(e))
1227
+
1228
+ def update_visibility_settings(self):
1229
+ """Update visibility settings."""
1230
+ try:
1231
+ content_length = int(self.headers.get("Content-Length", 0))
1232
+ body = self.rfile.read(content_length)
1233
+ data = json.loads(body.decode())
1234
+ result = _set_visibility_settings(data)
1235
+ response = json.dumps(result)
1236
+ self.send_response(200)
1237
+ self.send_header("Content-Type", "application/json")
1238
+ self.send_header("Access-Control-Allow-Origin", "*")
1239
+ self.send_header("Content-Length", len(response.encode()))
1240
+ self.end_headers()
1241
+ self.wfile.write(response.encode())
1242
+ except json.JSONDecodeError:
1243
+ self.send_error(400, "Invalid JSON")
1244
+ except Exception as e:
1245
+ self.send_error(500, str(e))
1246
+
1247
+ def update_story_visibility(self):
1248
+ """Update visibility for a specific story."""
1249
+ try:
1250
+ # Extract story_id from path: /api/stories/{story_id}/visibility
1251
+ parts = self.path.split("/")
1252
+ story_id = parts[3] if len(parts) > 4 else ""
1253
+
1254
+ content_length = int(self.headers.get("Content-Length", 0))
1255
+ body = self.rfile.read(content_length)
1256
+ data = json.loads(body.decode())
1257
+ result = _set_story_visibility(story_id, data.get("visibility", ""))
1258
+ response = json.dumps(result)
1259
+ self.send_response(200)
1260
+ self.send_header("Content-Type", "application/json")
1261
+ self.send_header("Access-Control-Allow-Origin", "*")
1262
+ self.send_header("Content-Length", len(response.encode()))
1263
+ self.end_headers()
1264
+ self.wfile.write(response.encode())
1265
+ except json.JSONDecodeError:
1266
+ self.send_error(400, "Invalid JSON")
1267
+ except Exception as e:
1268
+ self.send_error(500, str(e))
1269
+
1270
+
1271
+ def run_server(port: int, host: str, skip_update_check: bool = False) -> None:
1272
+ """
1273
+ Start the dashboard HTTP server.
1274
+
1275
+ Args:
1276
+ port: Port to listen on
1277
+ host: Host to bind to
1278
+ skip_update_check: If True, skip checking for dashboard updates
1279
+ """
1280
+ global _dashboard_dir
1281
+
1282
+ # Check for updates (non-blocking, best-effort)
1283
+ if not skip_update_check:
1284
+ try:
1285
+ check_for_updates(quiet=True)
1286
+ except Exception:
1287
+ pass # Don't fail startup if update check fails
1288
+
1289
+ # Ensure dashboard is available
1290
+ from .manager import ensure_dashboard
1291
+ #_dashboard_dir = ensure_dashboard()
1292
+
1293
+ handler = TimelineHandler
1294
+ with socketserver.TCPServer((host, port), handler) as server:
1295
+ try:
1296
+ server.serve_forever()
1297
+ except KeyboardInterrupt:
1298
+ pass