@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,121 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync, spawn } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const VERSION = '1.0.0';
9
+ const REPO = 'https://github.com/pnjegan/claudash';
10
+ const INSTALL_DIR = path.join(os.homedir(), '.claudash');
11
+
12
+ function checkPython() {
13
+ try {
14
+ const ver = execSync('python3 --version 2>&1').toString().trim();
15
+ const match = ver.match(/(\d+)\.(\d+)/);
16
+ if (match && parseInt(match[1]) >= 3 && parseInt(match[2]) >= 8) {
17
+ return true;
18
+ }
19
+ console.error('Python 3.8+ required. Found: ' + ver);
20
+ process.exit(1);
21
+ } catch (e) {
22
+ console.error('Python 3 not found. Install from https://python.org');
23
+ process.exit(1);
24
+ }
25
+ }
26
+
27
+ function checkClaudeData() {
28
+ const candidates = [
29
+ path.join(os.homedir(), '.claude', 'projects'),
30
+ path.join(os.homedir(), 'AppData', 'Roaming', 'Claude', 'projects'),
31
+ path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'projects'),
32
+ ];
33
+ const found = candidates.filter(p => fs.existsSync(p));
34
+ if (found.length === 0) {
35
+ console.log('Warning: No Claude Code data found.');
36
+ console.log(' Run at least one Claude Code session first.');
37
+ console.log(' Looked in:');
38
+ candidates.forEach(c => console.log(' ' + c));
39
+ console.log(' Starting dashboard anyway — it will show instructions.');
40
+ } else {
41
+ console.log('Found Claude Code data at: ' + found[0]);
42
+ }
43
+ return found;
44
+ }
45
+
46
+ function installClaudash() {
47
+ if (fs.existsSync(path.join(INSTALL_DIR, 'cli.py'))) {
48
+ try {
49
+ execSync('git -C "' + INSTALL_DIR + '" pull --quiet 2>/dev/null');
50
+ console.log('Claudash updated');
51
+ } catch (e) {
52
+ // offline or not a git repo — use what we have
53
+ }
54
+ return;
55
+ }
56
+
57
+ console.log('Installing Claudash to ' + INSTALL_DIR + '...');
58
+ try {
59
+ execSync('git clone --depth=1 --quiet "' + REPO + '" "' + INSTALL_DIR + '"');
60
+ console.log('Claudash installed');
61
+ } catch (e) {
62
+ console.error('Failed to clone from GitHub: ' + e.message);
63
+ console.error('Check your internet connection or visit: ' + REPO);
64
+ process.exit(1);
65
+ }
66
+ }
67
+
68
+ function openBrowser(port) {
69
+ const url = 'http://localhost:' + port;
70
+ const platform = process.platform;
71
+ setTimeout(() => {
72
+ try {
73
+ if (platform === 'darwin') execSync('open "' + url + '"');
74
+ else if (platform === 'win32') execSync('start "" "' + url + '"');
75
+ else execSync('xdg-open "' + url + '" 2>/dev/null || true');
76
+ } catch (e) { /* headless — no browser */ }
77
+ }, 1500);
78
+ }
79
+
80
+ function main() {
81
+ const args = process.argv.slice(2);
82
+
83
+ if (args.includes('--help') || args.includes('-h')) {
84
+ console.log('Claudash v' + VERSION + ' — Claude Code usage intelligence');
85
+ console.log('');
86
+ console.log('Usage: npx claudash [options]');
87
+ console.log('');
88
+ console.log('Options:');
89
+ console.log(' --port=N Dashboard port (default: 8080)');
90
+ console.log(' --no-browser Skip auto-opening browser');
91
+ console.log(' --help Show this help');
92
+ process.exit(0);
93
+ }
94
+
95
+ const portArg = args.find(a => a.startsWith('--port='));
96
+ const port = portArg ? portArg.split('=')[1] : '8080';
97
+ const noBrowser = args.includes('--no-browser');
98
+
99
+ console.log('Claudash v' + VERSION);
100
+ console.log('-'.repeat(40));
101
+
102
+ checkPython();
103
+ checkClaudeData();
104
+ installClaudash();
105
+
106
+ console.log('Starting dashboard on http://localhost:' + port + ' ...');
107
+ if (!noBrowser) {
108
+ openBrowser(port);
109
+ }
110
+
111
+ const cliArgs = [path.join(INSTALL_DIR, 'cli.py'), 'dashboard', '--port', port, '--no-browser'];
112
+ const proc = spawn('python3', cliArgs, {
113
+ stdio: 'inherit',
114
+ cwd: INSTALL_DIR,
115
+ });
116
+
117
+ proc.on('exit', code => process.exit(code || 0));
118
+ process.on('SIGINT', () => { proc.kill(); process.exit(0); });
119
+ }
120
+
121
+ main();
@@ -0,0 +1,358 @@
1
+ """Poll claude.ai browser usage API for configured accounts.
2
+ Session keys stored in SQLite only — never logged or written to files."""
3
+
4
+ import json
5
+ import sys
6
+ import time
7
+ import ssl
8
+ import threading
9
+ from datetime import datetime, timezone
10
+ from urllib.request import Request, urlopen
11
+ from urllib.error import HTTPError, URLError
12
+
13
+ from db import (
14
+ get_conn, get_claude_ai_accounts_all, get_claude_ai_account,
15
+ update_claude_ai_account_status, insert_claude_ai_snapshot,
16
+ get_latest_claude_ai_snapshot, upsert_claude_ai_account,
17
+ )
18
+
19
+ _last_poll_time = 0
20
+ _account_statuses = {}
21
+
22
+
23
+ def get_last_poll_time():
24
+ return _last_poll_time
25
+
26
+
27
+ def get_account_statuses():
28
+ return dict(_account_statuses)
29
+
30
+
31
+ def _ssl_ctx():
32
+ return ssl.create_default_context()
33
+
34
+
35
+ def _parse_iso(ts_str):
36
+ """Parse ISO 8601 timestamp to epoch int."""
37
+ if not ts_str:
38
+ return 0
39
+ try:
40
+ clean = ts_str.replace("Z", "").replace("+00:00", "")
41
+ if "." in clean:
42
+ clean = clean.split(".")[0]
43
+ dt = datetime.strptime(clean, "%Y-%m-%dT%H:%M:%S")
44
+ return int(dt.replace(tzinfo=timezone.utc).timestamp())
45
+ except Exception:
46
+ return 0
47
+
48
+
49
+ def _safe_request(url, session_key, method="GET"):
50
+ """Make an authenticated request to claude.ai. Returns (data_dict, None) or (None, error_str).
51
+ NEVER logs session_key."""
52
+ headers = {
53
+ "Accept": "application/json",
54
+ "Content-Type": "application/json",
55
+ "Cookie": f"sessionKey={session_key}",
56
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
57
+ }
58
+ req = Request(url, headers=headers, method=method)
59
+ try:
60
+ with urlopen(req, timeout=15, context=_ssl_ctx()) as resp:
61
+ body = resp.read().decode("utf-8")
62
+ return json.loads(body), None
63
+ except HTTPError as e:
64
+ if e.code in (401, 403):
65
+ return None, "expired"
66
+ return None, f"http_{e.code}"
67
+ except (URLError, OSError, json.JSONDecodeError, ValueError) as e:
68
+ return None, f"network_error"
69
+
70
+
71
+ # ── Public API ──
72
+
73
+ def fetch_org_id(session_key):
74
+ """GET https://claude.ai/api/account → extract organizations[0].uuid.
75
+ Returns org_id string or None."""
76
+ data, err = _safe_request("https://claude.ai/api/account", session_key)
77
+ if err or not data:
78
+ return None
79
+ # Response shape: {"account_uuid": ..., "organizations": [{"uuid": "..."}]}
80
+ orgs = data.get("organizations") or data.get("memberships", [])
81
+ if isinstance(orgs, list) and orgs:
82
+ org = orgs[0]
83
+ return org.get("uuid") or org.get("organization", {}).get("uuid") or org.get("id")
84
+ # Fallback: top-level uuid
85
+ return data.get("uuid") or data.get("org_id")
86
+
87
+
88
+ def verify_session(session_key):
89
+ """Verify a session key is valid. Returns {valid, org_id, error}."""
90
+ if not session_key or not session_key.strip():
91
+ return {"valid": False, "org_id": None, "error": "Session key is empty"}
92
+
93
+ org_id = fetch_org_id(session_key)
94
+ if org_id:
95
+ return {"valid": True, "org_id": org_id, "error": None}
96
+
97
+ # Try direct — maybe the API shape changed
98
+ data, err = _safe_request("https://claude.ai/api/account", session_key)
99
+ if err == "expired":
100
+ return {"valid": False, "org_id": None, "error": "Session key invalid or expired"}
101
+ if err:
102
+ return {"valid": False, "org_id": None, "error": f"Connection error: {err}"}
103
+ return {"valid": False, "org_id": None, "error": "Could not extract org_id from response"}
104
+
105
+
106
+ def fetch_usage(session_key, org_id, plan="max"):
107
+ """Fetch usage for an account. Returns normalized dict or None.
108
+ For Max: token-based window. For Pro: message-based."""
109
+ if not session_key or not org_id:
110
+ return None
111
+
112
+ url = f"https://claude.ai/api/organizations/{org_id}/usage"
113
+ data, err = _safe_request(url, session_key)
114
+
115
+ if err == "expired":
116
+ return {"error": "expired"}
117
+ if err or not data:
118
+ return {"error": err or "unknown"}
119
+
120
+ raw = json.dumps(data)
121
+
122
+ tokens_used = 0
123
+ tokens_limit = 0
124
+ messages_used = 0
125
+ messages_limit = 0
126
+ window_start = 0
127
+ window_end = 0
128
+
129
+ # Parse reset_at → window_end
130
+ reset_at = data.get("reset_at") or data.get("expires_at") or data.get("window_end")
131
+ if isinstance(reset_at, str):
132
+ window_end = _parse_iso(reset_at)
133
+ elif isinstance(reset_at, (int, float)):
134
+ window_end = int(reset_at)
135
+
136
+ # window_start = window_end - 5 hours (standard Anthropic window)
137
+ if window_end > 0:
138
+ window_start = window_end - (5 * 3600)
139
+
140
+ # Try extracting message counts (Pro plan)
141
+ if "messageLimit" in data:
142
+ ml = data["messageLimit"]
143
+ remaining = ml.get("remaining", 0) or 0
144
+ limit = ml.get("limit", 0) or 0
145
+ messages_limit = limit
146
+ messages_used = limit - remaining if limit > 0 else 0
147
+
148
+ if "raw_message_count" in data:
149
+ messages_used = data["raw_message_count"]
150
+ if "message_limit" in data:
151
+ messages_limit = data["message_limit"]
152
+
153
+ # Token-based usage (Max plan)
154
+ if "usage" in data and isinstance(data["usage"], dict):
155
+ u = data["usage"]
156
+ tokens_used = u.get("tokens_used", 0) or u.get("used", 0) or 0
157
+ tokens_limit = u.get("tokens_limit", 0) or u.get("limit", 0) or 0
158
+
159
+ # Fallback: top-level token fields
160
+ if tokens_used == 0 and tokens_limit == 0:
161
+ for key in ("tokens_used", "used_tokens", "current_usage", "used"):
162
+ if key in data and data[key]:
163
+ tokens_used = data[key]
164
+ break
165
+ for key in ("tokens_limit", "token_limit", "limit", "max_tokens"):
166
+ if key in data and data[key]:
167
+ tokens_limit = data[key]
168
+ break
169
+
170
+ # Compute unified pct_used
171
+ if plan == "pro" and messages_limit > 0:
172
+ pct_used = round(messages_used / messages_limit * 100, 1)
173
+ elif tokens_limit > 0:
174
+ pct_used = round(tokens_used / tokens_limit * 100, 1)
175
+ else:
176
+ pct_used = 0
177
+
178
+ return {
179
+ "tokens_used": tokens_used,
180
+ "tokens_limit": tokens_limit,
181
+ "messages_used": messages_used,
182
+ "messages_limit": messages_limit,
183
+ "window_start": window_start,
184
+ "window_end": window_end,
185
+ "pct_used": pct_used,
186
+ "plan": plan,
187
+ "raw": raw,
188
+ }
189
+
190
+
191
+ def poll_single(account_id, conn=None):
192
+ """Poll a single account. Returns snapshot dict or None."""
193
+ should_close = False
194
+ if conn is None:
195
+ conn = get_conn()
196
+ should_close = True
197
+
198
+ acct = get_claude_ai_account(conn, account_id)
199
+ if not acct:
200
+ if should_close:
201
+ conn.close()
202
+ return None
203
+
204
+ sk = acct.get("session_key", "").strip()
205
+ oid = acct.get("org_id", "").strip()
206
+ plan = acct.get("plan", "max")
207
+ label = acct.get("label", account_id)
208
+
209
+ # Skip mac-sync accounts — Mac pushes data, VPS doesn't poll
210
+ if acct.get("mac_sync_mode"):
211
+ print(f"[claude.ai] {label}: mac-sync mode, skipping poll", file=sys.stderr)
212
+ if should_close:
213
+ conn.close()
214
+ return None
215
+
216
+ if not sk or not oid:
217
+ _account_statuses[account_id] = {"status": "unconfigured", "label": label}
218
+ if should_close:
219
+ conn.close()
220
+ return None
221
+
222
+ result = fetch_usage(sk, oid, plan)
223
+ if not result:
224
+ update_claude_ai_account_status(conn, account_id, "error", "No response")
225
+ _account_statuses[account_id] = {"status": "error", "label": label, "error": "No response"}
226
+ if should_close:
227
+ conn.close()
228
+ return None
229
+
230
+ if "error" in result:
231
+ err = result["error"]
232
+ if err == "expired":
233
+ update_claude_ai_account_status(conn, account_id, "expired", "Session expired")
234
+ _account_statuses[account_id] = {"status": "expired", "label": label}
235
+ else:
236
+ update_claude_ai_account_status(conn, account_id, "error", err)
237
+ _account_statuses[account_id] = {"status": "error", "label": label, "error": err}
238
+ if should_close:
239
+ conn.close()
240
+ return None
241
+
242
+ # Success
243
+ insert_claude_ai_snapshot(conn, account_id, result)
244
+ update_claude_ai_account_status(conn, account_id, "active", None)
245
+
246
+ _account_statuses[account_id] = {
247
+ "status": "active",
248
+ "label": label,
249
+ "pct_used": result["pct_used"],
250
+ "tokens_used": result["tokens_used"],
251
+ "tokens_limit": result["tokens_limit"],
252
+ "messages_used": result["messages_used"],
253
+ "messages_limit": result["messages_limit"],
254
+ "plan": plan,
255
+ }
256
+
257
+ if plan == "pro" and result["messages_limit"] > 0:
258
+ print(f"[claude.ai] {label}: {result['messages_used']}/{result['messages_limit']} messages used", file=sys.stderr)
259
+ else:
260
+ print(f"[claude.ai] {label}: {result['pct_used']}% used", file=sys.stderr)
261
+
262
+ if should_close:
263
+ conn.close()
264
+ return result
265
+
266
+
267
+ def poll_all():
268
+ """Poll all configured claude.ai accounts (status != unconfigured)."""
269
+ global _last_poll_time
270
+ conn = get_conn()
271
+ accounts = get_claude_ai_accounts_all(conn)
272
+ count = 0
273
+
274
+ for acct in accounts:
275
+ aid = acct["account_id"]
276
+ sk = (acct.get("session_key") or "").strip()
277
+ oid = (acct.get("org_id") or "").strip()
278
+ label = acct.get("label", aid)
279
+
280
+ # Skip mac-sync accounts — Mac pushes data, VPS doesn't poll
281
+ if acct.get("mac_sync_mode"):
282
+ print(f"[claude.ai] {label}: mac-sync mode, skipping poll", file=sys.stderr)
283
+ continue
284
+
285
+ if not sk or not oid:
286
+ _account_statuses[aid] = {"status": "unconfigured", "label": label}
287
+ continue
288
+
289
+ try:
290
+ result = poll_single(aid, conn)
291
+ if result and "error" not in result:
292
+ count += 1
293
+ except Exception as e:
294
+ print(f"[claude.ai] Error polling {aid}: {e}", file=sys.stderr)
295
+ _account_statuses[aid] = {"status": "error", "label": label, "error": str(e)}
296
+
297
+ conn.close()
298
+ _last_poll_time = int(time.time())
299
+ print(f"[claude.ai] Poll complete: {count}/{len(accounts)} accounts updated", file=sys.stderr)
300
+ return count
301
+
302
+
303
+ def start_periodic_poll(interval_seconds=300):
304
+ def _run():
305
+ while True:
306
+ try:
307
+ poll_all()
308
+ except Exception as e:
309
+ print(f"[claude.ai] Periodic poll error: {e}", file=sys.stderr)
310
+ time.sleep(interval_seconds)
311
+
312
+ t = threading.Thread(target=_run, daemon=True)
313
+ t.start()
314
+ return t
315
+
316
+
317
+ def setup_account(account_id, session_key):
318
+ """Verify session key, extract org_id, store in DB, poll immediately.
319
+ Returns {success, error, org_id, plan, pct_used, label}."""
320
+ conn = get_conn()
321
+
322
+ # Get account info
323
+ acct_row = conn.execute("SELECT label, plan FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
324
+ if not acct_row:
325
+ conn.close()
326
+ return {"success": False, "error": f"Account '{account_id}' not found"}
327
+
328
+ label = acct_row["label"]
329
+ plan = acct_row["plan"]
330
+
331
+ # Verify session
332
+ verification = verify_session(session_key)
333
+ if not verification["valid"]:
334
+ conn.close()
335
+ return {"success": False, "error": verification["error"]}
336
+
337
+ org_id = verification["org_id"]
338
+
339
+ # Store in DB
340
+ upsert_claude_ai_account(conn, account_id, label, org_id, session_key, plan, "active")
341
+
342
+ # Immediately poll
343
+ result = fetch_usage(session_key, org_id, plan)
344
+ pct_used = 0
345
+ if result and "error" not in result:
346
+ insert_claude_ai_snapshot(conn, account_id, result)
347
+ update_claude_ai_account_status(conn, account_id, "active", None)
348
+ pct_used = result.get("pct_used", 0)
349
+
350
+ conn.close()
351
+
352
+ return {
353
+ "success": True,
354
+ "org_id": org_id,
355
+ "plan": plan,
356
+ "pct_used": pct_used,
357
+ "label": label,
358
+ }