claude-stats 0.1.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,35 @@
1
+ import { MonitorData } from '@claude-monitor/shared';
2
+ export interface StreamerOptions {
3
+ server: string;
4
+ token: string;
5
+ name?: string;
6
+ interval?: number;
7
+ getAccountId: () => string;
8
+ }
9
+ interface HeartbeatResponse {
10
+ needsData: boolean;
11
+ clientCount: number;
12
+ }
13
+ export declare class Streamer {
14
+ private server;
15
+ private token;
16
+ private machineId;
17
+ private machineName;
18
+ private interval;
19
+ private getAccountId;
20
+ private isRunning;
21
+ private intervalId;
22
+ private consecutiveErrors;
23
+ private maxRetries;
24
+ private lastClientCount;
25
+ constructor(options: StreamerOptions);
26
+ /**
27
+ * Check with server if any clients are viewing the dashboard
28
+ */
29
+ checkHeartbeat(): Promise<HeartbeatResponse>;
30
+ send(data: MonitorData): Promise<boolean>;
31
+ streamOnce(collectFn: () => MonitorData): Promise<boolean>;
32
+ start(collectFn: () => MonitorData): void;
33
+ stop(): void;
34
+ }
35
+ export {};
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Streamer = void 0;
37
+ const os = __importStar(require("os"));
38
+ const crypto = __importStar(require("crypto"));
39
+ // Generate a stable machine ID based on hostname and username
40
+ function getMachineId() {
41
+ const hostname = os.hostname();
42
+ const username = os.userInfo().username;
43
+ const combined = `${hostname}-${username}`;
44
+ return crypto.createHash('md5').update(combined).digest('hex').slice(0, 12);
45
+ }
46
+ class Streamer {
47
+ constructor(options) {
48
+ this.isRunning = false;
49
+ this.intervalId = null;
50
+ this.consecutiveErrors = 0;
51
+ this.maxRetries = 5;
52
+ this.lastClientCount = 0;
53
+ this.server = options.server.replace(/\/$/, ''); // Remove trailing slash
54
+ this.token = options.token;
55
+ this.machineId = getMachineId();
56
+ this.machineName = options.name || os.hostname();
57
+ this.interval = options.interval || 10000;
58
+ this.getAccountId = options.getAccountId;
59
+ }
60
+ /**
61
+ * Check with server if any clients are viewing the dashboard
62
+ */
63
+ async checkHeartbeat() {
64
+ try {
65
+ const response = await fetch(`${this.server}/api/heartbeat`, {
66
+ method: 'GET',
67
+ headers: {
68
+ Authorization: `Bearer ${this.token}`,
69
+ },
70
+ });
71
+ if (!response.ok) {
72
+ return { needsData: true, clientCount: 0 }; // Default to sending on error
73
+ }
74
+ return (await response.json());
75
+ }
76
+ catch {
77
+ return { needsData: true, clientCount: 0 }; // Default to sending on error
78
+ }
79
+ }
80
+ async send(data) {
81
+ const payload = {
82
+ machine_id: this.machineId,
83
+ machine_name: this.machineName,
84
+ account_id: this.getAccountId(),
85
+ timestamp: new Date().toISOString(),
86
+ data,
87
+ };
88
+ try {
89
+ const response = await fetch(`${this.server}/api/stream`, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ Authorization: `Bearer ${this.token}`,
94
+ },
95
+ body: JSON.stringify(payload),
96
+ });
97
+ if (!response.ok) {
98
+ const text = await response.text();
99
+ console.error(`Server error: ${response.status} - ${text}`);
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ catch (error) {
105
+ console.error(`Network error: ${error instanceof Error ? error.message : error}`);
106
+ return false;
107
+ }
108
+ }
109
+ async streamOnce(collectFn) {
110
+ try {
111
+ const data = collectFn();
112
+ const success = await this.send(data);
113
+ if (success) {
114
+ this.consecutiveErrors = 0;
115
+ }
116
+ else {
117
+ this.consecutiveErrors++;
118
+ }
119
+ return success;
120
+ }
121
+ catch (error) {
122
+ console.error(`Collection error: ${error instanceof Error ? error.message : error}`);
123
+ this.consecutiveErrors++;
124
+ return false;
125
+ }
126
+ }
127
+ start(collectFn) {
128
+ if (this.isRunning) {
129
+ console.log('Streamer is already running');
130
+ return;
131
+ }
132
+ this.isRunning = true;
133
+ console.log(`Starting streamer (smart polling)...`);
134
+ console.log(` Machine ID: ${this.machineId}`);
135
+ console.log(` Machine Name: ${this.machineName}`);
136
+ console.log(` Account ID: ${this.getAccountId()}`);
137
+ console.log(` Server: ${this.server}`);
138
+ console.log(` Interval: ${this.interval}ms`);
139
+ console.log('');
140
+ console.log('Data will only be sent when someone is viewing the dashboard.');
141
+ console.log('');
142
+ // Initial send to register the machine
143
+ this.streamOnce(collectFn).then((success) => {
144
+ if (success) {
145
+ console.log(`✓ Connected to server`);
146
+ }
147
+ else {
148
+ console.log(`✗ Failed to connect to server`);
149
+ }
150
+ });
151
+ // Start interval with smart polling
152
+ this.intervalId = setInterval(async () => {
153
+ // Check if anyone is viewing the dashboard
154
+ const heartbeat = await this.checkHeartbeat();
155
+ // Log when client count changes
156
+ if (heartbeat.clientCount !== this.lastClientCount) {
157
+ if (heartbeat.clientCount > 0) {
158
+ console.log(`\n👀 ${heartbeat.clientCount} viewer(s) connected - streaming data`);
159
+ }
160
+ else {
161
+ console.log(`\n💤 No viewers - pausing data stream`);
162
+ }
163
+ this.lastClientCount = heartbeat.clientCount;
164
+ }
165
+ if (!heartbeat.needsData) {
166
+ // No one viewing, just send a lightweight heartbeat indicator
167
+ process.stdout.write('_');
168
+ return;
169
+ }
170
+ // Someone is viewing, send full data
171
+ const success = await this.streamOnce(collectFn);
172
+ if (this.consecutiveErrors >= this.maxRetries) {
173
+ console.error(`\nToo many consecutive errors (${this.consecutiveErrors}), will keep trying...`);
174
+ this.consecutiveErrors = 0;
175
+ }
176
+ if (success) {
177
+ process.stdout.write('.');
178
+ }
179
+ else {
180
+ process.stdout.write('x');
181
+ }
182
+ }, this.interval);
183
+ // Handle graceful shutdown
184
+ process.on('SIGINT', () => this.stop());
185
+ process.on('SIGTERM', () => this.stop());
186
+ }
187
+ stop() {
188
+ if (!this.isRunning)
189
+ return;
190
+ console.log('\nStopping streamer...');
191
+ this.isRunning = false;
192
+ if (this.intervalId) {
193
+ clearInterval(this.intervalId);
194
+ this.intervalId = null;
195
+ }
196
+ process.exit(0);
197
+ }
198
+ }
199
+ exports.Streamer = Streamer;
@@ -0,0 +1,14 @@
1
+ import { UsageLimits } from '@claude-monitor/shared';
2
+ /**
3
+ * Get cached usage limits synchronously.
4
+ * Starts a background fetch if cache is stale.
5
+ */
6
+ export declare function getUsageLimitsSync(): UsageLimits | null;
7
+ /**
8
+ * Async function to fetch usage limits (for initial load)
9
+ */
10
+ export declare function fetchUsageLimits(): Promise<UsageLimits | null>;
11
+ /**
12
+ * Force refresh usage limits (clears cache and fetches fresh data)
13
+ */
14
+ export declare function forceRefreshUsageLimits(): Promise<UsageLimits | null>;
@@ -0,0 +1,325 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getUsageLimitsSync = getUsageLimitsSync;
37
+ exports.fetchUsageLimits = fetchUsageLimits;
38
+ exports.forceRefreshUsageLimits = forceRefreshUsageLimits;
39
+ const child_process_1 = require("child_process");
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const os = __importStar(require("os"));
43
+ // Cache the usage limits
44
+ let cachedLimits = null;
45
+ let lastFetchTime = 0;
46
+ let isFetching = false;
47
+ const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes memory cache
48
+ // Cache file for persisting usage data
49
+ const CACHE_FILE = path.join(os.homedir(), '.claude', 'usage-cache.json');
50
+ const FILE_CACHE_DURATION = 30 * 60 * 1000; // 30 minutes file cache
51
+ // OAuth API endpoint (like CodexBar)
52
+ const OAUTH_API_URL = 'https://api.anthropic.com/api/oauth/usage';
53
+ /**
54
+ * Load cached limits from file
55
+ */
56
+ function loadCacheFromFile() {
57
+ try {
58
+ if (fs.existsSync(CACHE_FILE)) {
59
+ const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
60
+ // Check if cache is still valid
61
+ if (data.fetched_at) {
62
+ const age = Date.now() - new Date(data.fetched_at).getTime();
63
+ if (age < FILE_CACHE_DURATION) {
64
+ return data;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ catch {
70
+ // Ignore errors
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * Save limits to cache file
76
+ */
77
+ function saveCacheToFile(limits) {
78
+ try {
79
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(limits, null, 2));
80
+ }
81
+ catch {
82
+ // Ignore errors
83
+ }
84
+ }
85
+ /**
86
+ * Get OAuth credentials from macOS Keychain
87
+ */
88
+ function getOAuthCredentials() {
89
+ try {
90
+ // Try macOS Keychain first
91
+ const result = (0, child_process_1.execSync)('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
92
+ const credentials = JSON.parse(result.trim());
93
+ if (credentials.claudeAiOauth?.accessToken) {
94
+ return {
95
+ accessToken: credentials.claudeAiOauth.accessToken,
96
+ refreshToken: credentials.claudeAiOauth.refreshToken,
97
+ expiresAt: credentials.claudeAiOauth.expiresAt,
98
+ scopes: credentials.claudeAiOauth.scopes,
99
+ };
100
+ }
101
+ }
102
+ catch {
103
+ // Keychain access failed, try file-based credentials
104
+ }
105
+ // Try ~/.claude/.credentials.json
106
+ try {
107
+ const credFile = path.join(os.homedir(), '.claude', '.credentials.json');
108
+ if (fs.existsSync(credFile)) {
109
+ const data = JSON.parse(fs.readFileSync(credFile, 'utf-8'));
110
+ if (data.claudeAiOauth?.accessToken) {
111
+ return {
112
+ accessToken: data.claudeAiOauth.accessToken,
113
+ refreshToken: data.claudeAiOauth.refreshToken,
114
+ expiresAt: data.claudeAiOauth.expiresAt,
115
+ scopes: data.claudeAiOauth.scopes,
116
+ };
117
+ }
118
+ }
119
+ }
120
+ catch {
121
+ // File access failed
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * Fetch usage via OAuth API (like CodexBar)
127
+ */
128
+ async function fetchUsageViaOAuthAPI() {
129
+ const credentials = getOAuthCredentials();
130
+ if (!credentials) {
131
+ console.log('[usage-limits] No OAuth credentials found');
132
+ return null;
133
+ }
134
+ // Check if token is expired
135
+ if (credentials.expiresAt) {
136
+ const expiresAt = new Date(credentials.expiresAt).getTime();
137
+ if (Date.now() > expiresAt) {
138
+ console.log('[usage-limits] OAuth token expired');
139
+ return null;
140
+ }
141
+ }
142
+ try {
143
+ const response = await fetch(OAUTH_API_URL, {
144
+ method: 'GET',
145
+ headers: {
146
+ 'Authorization': `Bearer ${credentials.accessToken}`,
147
+ 'Accept': 'application/json',
148
+ 'Content-Type': 'application/json',
149
+ 'anthropic-beta': 'oauth-2025-04-20',
150
+ 'User-Agent': 'claude-monitor-cli',
151
+ },
152
+ });
153
+ if (!response.ok) {
154
+ const text = await response.text();
155
+ console.log(`[usage-limits] OAuth API error: ${response.status} - ${text}`);
156
+ return null;
157
+ }
158
+ const data = (await response.json());
159
+ return parseOAuthResponse(data);
160
+ }
161
+ catch (error) {
162
+ console.log(`[usage-limits] OAuth API fetch error: ${error instanceof Error ? error.message : error}`);
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * Parse OAuth API response into UsageLimits
168
+ */
169
+ function parseOAuthResponse(data) {
170
+ const limits = {
171
+ session: null,
172
+ weekly: null,
173
+ fetched_at: new Date().toISOString(),
174
+ };
175
+ // Parse 5-hour session window
176
+ if (data.five_hour) {
177
+ const window = data.five_hour;
178
+ // API returns 'utilization' as the percentage used
179
+ const percentUsed = window.utilization ?? window.percent_used ?? (100 - (window.percent_remaining ?? 0));
180
+ const resetTime = window.resets_at || window.reset_at || null;
181
+ limits.session = {
182
+ percent_used: Math.round(percentUsed),
183
+ resets_at: resetTime,
184
+ reset_description: resetTime ? formatResetTime(resetTime) : null,
185
+ };
186
+ }
187
+ // Parse 7-day weekly window
188
+ if (data.seven_day) {
189
+ const window = data.seven_day;
190
+ const percentUsed = window.utilization ?? window.percent_used ?? (100 - (window.percent_remaining ?? 0));
191
+ const resetTime = window.resets_at || window.reset_at || null;
192
+ limits.weekly = {
193
+ percent_used: Math.round(percentUsed),
194
+ resets_at: resetTime,
195
+ reset_description: resetTime ? formatResetTime(resetTime) : null,
196
+ };
197
+ }
198
+ if (limits.session || limits.weekly) {
199
+ return limits;
200
+ }
201
+ return null;
202
+ }
203
+ /**
204
+ * Format reset time as relative description
205
+ */
206
+ function formatResetTime(isoTime) {
207
+ try {
208
+ const resetTime = new Date(isoTime).getTime();
209
+ const now = Date.now();
210
+ const diffMs = resetTime - now;
211
+ if (diffMs <= 0)
212
+ return 'Soon';
213
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
214
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
215
+ if (hours > 24) {
216
+ const days = Math.floor(hours / 24);
217
+ return `${days}d ${hours % 24}h`;
218
+ }
219
+ if (hours > 0) {
220
+ return `${hours}h ${minutes}m`;
221
+ }
222
+ return `${minutes}m`;
223
+ }
224
+ catch {
225
+ return 'Unknown';
226
+ }
227
+ }
228
+ /**
229
+ * Get cached usage limits synchronously.
230
+ * Starts a background fetch if cache is stale.
231
+ */
232
+ function getUsageLimitsSync() {
233
+ // Try memory cache first
234
+ if (cachedLimits && Date.now() - lastFetchTime < CACHE_DURATION) {
235
+ return cachedLimits;
236
+ }
237
+ // Try file cache
238
+ const fileCache = loadCacheFromFile();
239
+ if (fileCache) {
240
+ cachedLimits = fileCache;
241
+ lastFetchTime = Date.now();
242
+ // Trigger background refresh if cache is getting old
243
+ const age = Date.now() - new Date(fileCache.fetched_at).getTime();
244
+ if (age > 10 * 60 * 1000 && !isFetching) {
245
+ fetchUsageLimitsBackground();
246
+ }
247
+ return cachedLimits;
248
+ }
249
+ // Start background fetch if needed
250
+ if (!isFetching) {
251
+ fetchUsageLimitsBackground();
252
+ }
253
+ return cachedLimits;
254
+ }
255
+ /**
256
+ * Fetch usage limits in the background without blocking
257
+ */
258
+ function fetchUsageLimitsBackground() {
259
+ if (isFetching)
260
+ return;
261
+ isFetching = true;
262
+ fetchUsageViaOAuthAPI()
263
+ .then((limits) => {
264
+ if (limits) {
265
+ cachedLimits = limits;
266
+ lastFetchTime = Date.now();
267
+ saveCacheToFile(limits);
268
+ console.log('[usage-limits] Fetched via OAuth API successfully');
269
+ }
270
+ else {
271
+ console.log('[usage-limits] OAuth API failed, no fallback available');
272
+ }
273
+ })
274
+ .catch((err) => {
275
+ console.log('[usage-limits] Error fetching usage:', err?.message || err);
276
+ })
277
+ .finally(() => {
278
+ isFetching = false;
279
+ });
280
+ }
281
+ /**
282
+ * Async function to fetch usage limits (for initial load)
283
+ */
284
+ async function fetchUsageLimits() {
285
+ // Try file cache first
286
+ const fileCache = loadCacheFromFile();
287
+ if (fileCache) {
288
+ // Start background refresh if cache is getting old (> 10 min)
289
+ const age = Date.now() - new Date(fileCache.fetched_at).getTime();
290
+ if (age > 10 * 60 * 1000 && !isFetching) {
291
+ fetchUsageLimitsBackground();
292
+ }
293
+ return fileCache;
294
+ }
295
+ // Try OAuth API
296
+ return fetchUsageViaOAuthAPI();
297
+ }
298
+ /**
299
+ * Force refresh usage limits (clears cache and fetches fresh data)
300
+ */
301
+ async function forceRefreshUsageLimits() {
302
+ cachedLimits = null;
303
+ lastFetchTime = 0;
304
+ // Delete cache file
305
+ try {
306
+ if (fs.existsSync(CACHE_FILE)) {
307
+ fs.unlinkSync(CACHE_FILE);
308
+ }
309
+ }
310
+ catch {
311
+ // Ignore
312
+ }
313
+ console.log('[usage-limits] Force refreshing via OAuth API...');
314
+ const limits = await fetchUsageViaOAuthAPI();
315
+ if (limits) {
316
+ cachedLimits = limits;
317
+ lastFetchTime = Date.now();
318
+ saveCacheToFile(limits);
319
+ console.log('[usage-limits] Force refresh successful');
320
+ }
321
+ else {
322
+ console.log('[usage-limits] Force refresh failed');
323
+ }
324
+ return limits;
325
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "claude-stats",
3
+ "version": "0.1.0",
4
+ "description": "Monitor and stream Claude Code usage statistics to a remote dashboard",
5
+ "keywords": [
6
+ "claude",
7
+ "anthropic",
8
+ "cli",
9
+ "usage",
10
+ "monitor",
11
+ "stats",
12
+ "tokens"
13
+ ],
14
+ "author": "Claude Monitor",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/projectashik/claude-monitor"
19
+ },
20
+ "bin": {
21
+ "claude-stats": "./dist/index.js"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "scripts": {
27
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --external:commander --banner:js='#!/usr/bin/env node'",
28
+ "build:tsc": "tsc",
29
+ "dev": "tsc --watch",
30
+ "stream": "tsx src/index.ts",
31
+ "start": "node dist/index.js",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "dependencies": {
35
+ "commander": "^12.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@claude-monitor/shared": "*",
39
+ "@types/node": "^22.0.0",
40
+ "esbuild": "^0.24.0",
41
+ "tsx": "^4.7.0",
42
+ "typescript": "^5.7.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ }
47
+ }