dashboard-for-claude-code 0.1.0__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.
File without changes
@@ -0,0 +1,55 @@
1
+ import argparse
2
+ import os
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ import uvicorn
6
+
7
+
8
+ def _dist_version() -> str:
9
+ try:
10
+ return version("dashboard-for-claude-code")
11
+ except PackageNotFoundError:
12
+ return "unknown (running from source without install)"
13
+
14
+
15
+ def main(argv: list[str] | None = None):
16
+ parser = argparse.ArgumentParser(
17
+ prog="dashboard-for-claude-code",
18
+ description="Claude Code Personal Analytics — a private, local, read-only analytics dashboard for your Claude Code sessions.",
19
+ )
20
+ parser.add_argument(
21
+ "--host",
22
+ help="interface to bind; overrides DASHBOARD_HOST (default: 127.0.0.1, "
23
+ "localhost-only — use 0.0.0.0 to expose on your LAN)",
24
+ )
25
+ parser.add_argument(
26
+ "--port", type=int,
27
+ help="port to serve on; overrides DASHBOARD_PORT (default: 8042)",
28
+ )
29
+ parser.add_argument(
30
+ "--version", action="version", version=f"%(prog)s {_dist_version()}",
31
+ )
32
+ args = parser.parse_args(argv)
33
+
34
+ host = args.host or os.environ.get("DASHBOARD_HOST", "127.0.0.1")
35
+ if args.port is not None:
36
+ port = args.port
37
+ else:
38
+ try:
39
+ port = int(os.environ.get("DASHBOARD_PORT", "8042"))
40
+ except ValueError:
41
+ print("ERROR: DASHBOARD_PORT must be an integer", flush=True)
42
+ raise SystemExit(1)
43
+
44
+ if args.host:
45
+ # app.py derives its Host-header guard (and cookie secure flag) from
46
+ # DASHBOARD_HOST at import time, so the override must land in the
47
+ # environment before the app module is imported below.
48
+ os.environ["DASHBOARD_HOST"] = args.host
49
+
50
+ from claude_dashboard.app import app
51
+ uvicorn.run(app, host=host, port=port)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,276 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import logging
5
+ import os
6
+ import threading
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from fastapi import FastAPI, HTTPException, Query, Request, Response
12
+ from fastapi.responses import FileResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ from claude_dashboard.scanner import RefreshReport, refresh
16
+ from claude_dashboard.store import Store
17
+
18
+ _log = logging.getLogger("claude_dashboard")
19
+
20
+ # DB location. DASHBOARD_DB lets tests, demos, and the screenshot generator
21
+ # point at an isolated database so a run never has to touch (or scan) your
22
+ # real ~/.claude data.
23
+ def _default_db() -> Path:
24
+ # Source checkout (repo root has pyproject.toml): keep the DB in-tree at
25
+ # data/usage.db, as documented. Installed package (pipx/uvx/pip): the tree
26
+ # location would land inside site-packages and be wiped on reinstall, so
27
+ # use a per-user data dir instead.
28
+ root = Path(__file__).resolve().parent.parent.parent
29
+ if (root / "pyproject.toml").is_file():
30
+ return root / "data" / "usage.db"
31
+ base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
32
+ return base / "dashboard-for-claude-code" / "usage.db"
33
+
34
+
35
+ _DB_PATH = Path(os.environ.get("DASHBOARD_DB") or _default_db())
36
+ _STATIC = Path(__file__).parent / "static"
37
+
38
+ # Optional token auth — active only when DASHBOARD_AUTH_TOKEN is set.
39
+ # Accepts: Authorization: Bearer <token> OR cookie dashboard_auth=<token>
40
+ _AUTH_TOKEN: str | None = os.environ.get("DASHBOARD_AUTH_TOKEN") or None
41
+
42
+ # Loopback detection — used for DNS-rebinding protection (M3) and cookie
43
+ # secure flag (M5). True when bound to a loopback address (the default).
44
+ _HOST = os.environ.get("DASHBOARD_HOST", "127.0.0.1")
45
+ _LOOPBACK_ADDRS = {"127.0.0.1", "::1", "0:0:0:0:0:0:0:1"}
46
+ _IS_LOCALHOST = _HOST in _LOOPBACK_ADDRS
47
+ _LOOPBACK_HOST_NAMES = {"localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"}
48
+
49
+ _store: Store | None = None
50
+ _refresh_lock = threading.Lock()
51
+
52
+
53
+ def _host_name(raw_host: str) -> str:
54
+ """Extract the hostname from a Host header value.
55
+
56
+ Handles bracketed IPv6 literals ("[::1]:8042" → "::1"); a naive
57
+ split(":") would mangle them into "" and skip the loopback check.
58
+ """
59
+ raw_host = raw_host.strip()
60
+ if raw_host.startswith("["):
61
+ end = raw_host.find("]")
62
+ return raw_host[1:end] if end != -1 else ""
63
+ return raw_host.split(":")[0]
64
+
65
+
66
+ @asynccontextmanager
67
+ async def _lifespan(app: FastAPI):
68
+ global _store
69
+ _store = Store(_DB_PATH)
70
+ # Initial scan in background so startup is fast. DASHBOARD_NO_SCAN=1 disables
71
+ # it entirely (used by tests and the screenshot generator, which supply their
72
+ # own pre-populated DB and must never read ~/.claude).
73
+ if not os.environ.get("DASHBOARD_NO_SCAN"):
74
+ threading.Thread(target=_do_refresh, daemon=True).start()
75
+ try:
76
+ yield
77
+ finally:
78
+ if _store:
79
+ _store.close()
80
+
81
+
82
+ app = FastAPI(title="Claude Code Personal Analytics", lifespan=_lifespan)
83
+
84
+
85
+ @app.middleware("http")
86
+ async def security_middleware(request: Request, call_next):
87
+ # ── DNS-rebinding protection (M3) ────────────────────────────────────────
88
+ # When bound to a loopback address, reject requests whose Host header
89
+ # isn't a recognized localhost name. This blocks a malicious page from
90
+ # reaching the unauthenticated API via a rebound hostname.
91
+ if _IS_LOCALHOST:
92
+ host_name = _host_name(request.headers.get("host", ""))
93
+ # An absent/empty Host is rejected too: every legitimate client
94
+ # (HTTP/1.1 requires Host) sends one, and allowing it would let
95
+ # malformed requests skip the check.
96
+ if host_name not in _LOOPBACK_HOST_NAMES:
97
+ return JSONResponse({"detail": "Forbidden"}, status_code=403)
98
+
99
+ # ── Optional token auth ──────────────────────────────────────────────────
100
+ if _AUTH_TOKEN:
101
+ # Allow the login page (GET /) and static assets even without auth,
102
+ # so the browser can render the 401 page gracefully.
103
+ skip = request.url.path in ("/", "/overview", "/projects", "/sessions", "/calendar",
104
+ "/settings", "/api/login") \
105
+ or request.url.path.startswith("/static/")
106
+ if not skip:
107
+ bearer = request.headers.get("Authorization", "")
108
+ token = bearer.removeprefix("Bearer ").strip() if bearer.startswith("Bearer ") else ""
109
+ if not token:
110
+ token = request.cookies.get("dashboard_auth", "")
111
+ if not hmac.compare_digest(token, _AUTH_TOKEN):
112
+ return JSONResponse({"detail": "Unauthorized"}, status_code=401)
113
+
114
+ # ── No-cache for static assets (local mode) ──────────────────────────────
115
+ response = await call_next(request)
116
+ if request.url.path.startswith("/static/"):
117
+ response.headers["Cache-Control"] = "no-store"
118
+ return response
119
+
120
+
121
+ def _do_refresh(prune: bool = False) -> RefreshReport:
122
+ with _refresh_lock:
123
+ return refresh(_store, prune=prune)
124
+
125
+
126
+ # ── API ────────────────────────────────────────────────────────────────────
127
+
128
+ @app.get("/api/summary")
129
+ def api_summary():
130
+ return _store.summary()
131
+
132
+
133
+ @app.get("/api/projects")
134
+ def api_projects(include_hidden: bool = Query(False)):
135
+ return _store.list_projects(include_hidden=include_hidden)
136
+
137
+
138
+ @app.get("/api/project")
139
+ def api_project(path: str = Query(None), name: str = Query(None)):
140
+ if not path and not name:
141
+ raise HTTPException(400, "path or name required")
142
+ # include_hidden: hidden projects are excluded from listings, but their
143
+ # detail page must stay reachable via a direct link.
144
+ projects = _store.list_projects(include_hidden=True)
145
+ if path:
146
+ matched = [p for p in projects if p.get("project_path") == path]
147
+ else:
148
+ # Match by display name (project_name after rename applied)
149
+ matched = [p for p in projects if p.get("project_name") == name]
150
+ if not matched:
151
+ raise HTTPException(404, "Project not found")
152
+
153
+ if len(matched) == 1:
154
+ proj = matched[0]
155
+ proj["project_paths"] = [proj["project_path"]]
156
+ proj["sessions"] = _store.list_sessions(proj["project_path"])
157
+ return proj
158
+
159
+ # Multiple paths share the same display name — merge on the fly
160
+ all_paths = [p["project_path"] for p in matched]
161
+ base = dict(matched[0])
162
+ base["project_paths"] = all_paths
163
+ for p in matched[1:]:
164
+ for f in ("session_count", "user_rounds", "assistant_messages",
165
+ "api_duration_ms", "wall_duration_ms", "code_lines_added",
166
+ "code_lines_removed", "cost_usd"):
167
+ base[f] = (base.get(f) or 0) + (p.get(f) or 0)
168
+ base["last_active"] = max(base.get("last_active") or "", p.get("last_active") or "") or None
169
+ base["first_active"] = min(
170
+ base.get("first_active") or "\xff", p.get("first_active") or "\xff"
171
+ ).replace("\xff", "") or None
172
+ # merge tokens_by_model
173
+ for model, counts in (p.get("tokens_by_model") or {}).items():
174
+ if model not in base["tokens_by_model"]:
175
+ base["tokens_by_model"][model] = {"input": 0, "output": 0, "cache_read": 0,
176
+ "cache_write_5m": 0, "cache_write_1h": 0}
177
+ for k, v in counts.items():
178
+ base["tokens_by_model"][model][k] = base["tokens_by_model"][model].get(k, 0) + (v or 0)
179
+ for tool, cnt in (p.get("tools") or {}).items():
180
+ base["tools"][tool] = base["tools"].get(tool, 0) + cnt
181
+ base["sessions"] = _store.list_sessions_for_paths(all_paths)
182
+ return base
183
+
184
+
185
+ @app.get("/api/sessions")
186
+ def api_sessions():
187
+ return _store.list_sessions()
188
+
189
+
190
+ @app.get("/api/sessions/{session_id}")
191
+ def api_session(session_id: str):
192
+ s = _store.get_session(session_id)
193
+ if not s:
194
+ raise HTTPException(404, "Session not found")
195
+ return s
196
+
197
+
198
+ @app.get("/api/settings")
199
+ def api_get_settings():
200
+ return _store.get_all_project_settings()
201
+
202
+
203
+ @app.put("/api/settings")
204
+ async def api_put_setting(body: dict[str, Any]):
205
+ project_path = body.get("project_path")
206
+ if not project_path:
207
+ raise HTTPException(400, "project_path required")
208
+ _store.upsert_project_setting(
209
+ project_path,
210
+ body.get("display_name") or None,
211
+ bool(body.get("hidden", False)),
212
+ )
213
+ return {"ok": True}
214
+
215
+
216
+ @app.post("/api/login")
217
+ async def api_login(body: dict[str, Any], response: Response):
218
+ if not _AUTH_TOKEN:
219
+ raise HTTPException(404, "Auth not enabled")
220
+ token = body.get("token", "")
221
+ if not isinstance(token, str) or not hmac.compare_digest(token, _AUTH_TOKEN):
222
+ raise HTTPException(401, "Invalid token")
223
+ # Set the cookie from the trusted server-side _AUTH_TOKEN, not the request
224
+ # value: they are equal here (compare_digest passed), and using the vetted
225
+ # constant keeps user-supplied input out of the Set-Cookie header.
226
+ # secure=True on non-localhost so the cookie is HTTPS-only when hosted remotely.
227
+ response.set_cookie("dashboard_auth", _AUTH_TOKEN, httponly=True, samesite="strict",
228
+ secure=not _IS_LOCALHOST)
229
+ return {"ok": True}
230
+
231
+
232
+ @app.get("/api/summaries/missing")
233
+ def api_summaries_missing():
234
+ """Sessions that still need an AI summary, most recent first."""
235
+ return _store.sessions_missing_summary()
236
+
237
+
238
+ # Summaries are not stored in the DB: scripts/set_summaries.py writes
239
+ # data/session_summary.json (the source of truth) and Store reads it directly
240
+ # at query time. See Store.get_all_summaries.
241
+
242
+
243
+ @app.post("/api/refresh")
244
+ def api_refresh(prune: bool = Query(False)):
245
+ """Re-scan transcripts. prune=true also drops sessions whose transcript
246
+ file no longer exists on disk (by default deleted transcripts stay in the
247
+ cache so history survives Claude Code's own cleanup)."""
248
+ report = _do_refresh(prune=prune)
249
+ # Log the raw error strings (they contain filesystem paths and exception
250
+ # detail) server-side only; expose just a count to the client so a scan
251
+ # never leaks internal paths or stack-trace text.
252
+ if report.errors:
253
+ _log.warning("Refresh completed with %d error(s):\n%s",
254
+ len(report.errors), "\n".join(report.errors))
255
+ return {
256
+ "added": report.added,
257
+ "updated": report.updated,
258
+ "skipped": report.skipped,
259
+ "pruned": report.pruned,
260
+ "errors": len(report.errors),
261
+ }
262
+
263
+
264
+ # ── Static files ───────────────────────────────────────────────────────────
265
+
266
+ app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")
267
+
268
+
269
+ @app.get("/")
270
+ @app.get("/overview")
271
+ @app.get("/projects")
272
+ @app.get("/sessions")
273
+ @app.get("/calendar")
274
+ @app.get("/settings")
275
+ def index():
276
+ return FileResponse(str(_STATIC / "index.html"))