import { chromium, type BrowserContext } from 'playwright'; import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; const SESSION_DIR = path.join(process.env.HOME!, '.local/share/claude-usage/session'); const STATE_FILE = path.join(SESSION_DIR, 'state.json'); const USAGE_URL = 'https://claude.ai/settings/usage'; async function scrapeUsage(context: BrowserContext): Promise { const page = await context.newPage(); try { await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Wait for usage data to render (poll for "used" text) for (let i = 0; i < 15; i++) { await page.waitForTimeout(1000); const t = await page.evaluate(() => document.body?.innerText || ''); if (t.includes('% used')) break; } const text = await page.evaluate(() => document.body?.innerText || ''); if (text.includes('Log in') || text.includes('Continue with Google') || text.includes('Continue with email')) { return null; } if (text.includes('security verification') || text.includes('not a bot')) { await page.waitForTimeout(10000); const t2 = await page.evaluate(() => document.body?.innerText || ''); if (t2.includes('security verification')) return null; return t2; } // Re-save session state to keep it fresh await context.storageState({ path: STATE_FILE }); return text; } finally { await page.close(); } } function parseUsage(raw: string) { const lines = raw.split('\n').map(l => l.trim()).filter(Boolean); const result: Record = {}; // Extract "Current session" block const sessionIdx = lines.findIndex(l => l === 'Current session'); if (sessionIdx >= 0) { for (let i = sessionIdx + 1; i < Math.min(sessionIdx + 5, lines.length); i++) { const resetMatch = lines[i].match(/Resets (.+)/); if (resetMatch) result.sessionResets = resetMatch[1]; const pctMatch = lines[i].match(/(\d+)%\s*used/); if (pctMatch) result.sessionUsed = parseInt(pctMatch[1]); } } // Extract "All models" (weekly) const allModelsIdx = lines.findIndex(l => l === 'All models'); if (allModelsIdx >= 0) { for (let i = allModelsIdx + 1; i < Math.min(allModelsIdx + 5, lines.length); i++) { const resetMatch = lines[i].match(/Resets (.+)/); if (resetMatch) result.weeklyResets = resetMatch[1]; const pctMatch = lines[i].match(/(\d+)%\s*used/); if (pctMatch) result.weeklyUsed = parseInt(pctMatch[1]); } } // Extract "Sonnet only" const sonnetIdx = lines.findIndex(l => l === 'Sonnet only'); if (sonnetIdx >= 0) { for (let i = sonnetIdx + 1; i < Math.min(sonnetIdx + 5, lines.length); i++) { const pctMatch = lines[i].match(/(\d+)%\s*used/); if (pctMatch) result.sonnetUsed = parseInt(pctMatch[1]); } } // Extract extra usage const extraIdx = lines.findIndex(l => l.includes('Extra usage')); if (extraIdx >= 0) { for (let i = extraIdx + 1; i < Math.min(extraIdx + 5, lines.length); i++) { const spentMatch = lines[i].match(/\$([\d.]+)\s*spent/); if (spentMatch) result.extraSpent = spentMatch[1]; } } // Convert 12h to 24h format (e.g. "Sat 12:00 PM" -> "Sat 12:00", "Sun 1:30 AM" -> "Sun 01:30") for (const key of ['sessionResets', 'weeklyResets']) { if (result[key]) { result[key] = result[key].replace( /(\d{1,2}):(\d{2})\s*(AM|PM)/i, (_: string, h: string, m: string, ampm: string) => { let hour = parseInt(h); if (ampm.toUpperCase() === 'PM' && hour !== 12) hour += 12; if (ampm.toUpperCase() === 'AM' && hour === 12) hour = 0; return hour.toString().padStart(2, '0') + ':' + m; } ); } } return result; } function renderUsage(data: Record) { const GREEN = '\x1b[0;32m'; const YELLOW = '\x1b[1;33m'; const RED = '\x1b[0;31m'; const CYAN = '\x1b[0;36m'; const DIM = '\x1b[2m'; const BOLD = '\x1b[1m'; const NC = '\x1b[0m'; function bar(pct: number, width = 20): string { const filled = Math.round(pct * width / 100); const empty = width - filled; const color = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN; return color + '█'.repeat(filled) + NC + DIM + '░'.repeat(empty) + NC; } console.log(''); console.log(`${BOLD} Claude Usage${NC}`); console.log(' ──────────────────────────────'); if (data.sessionUsed !== undefined) { console.log(` ${CYAN}Session${NC} ${bar(data.sessionUsed)} ${BOLD}${data.sessionUsed}%${NC} ${DIM}resets ${data.sessionResets || '?'}${NC}`); } if (data.weeklyUsed !== undefined) { console.log(` ${CYAN}Weekly${NC} ${bar(data.weeklyUsed)} ${BOLD}${data.weeklyUsed}%${NC} ${DIM}resets ${data.weeklyResets || '?'}${NC}`); } if (data.sonnetUsed !== undefined) { console.log(` ${CYAN}Sonnet${NC} ${bar(data.sonnetUsed)} ${BOLD}${data.sonnetUsed}%${NC}`); } if (data.extraSpent !== undefined) { console.log(` ${DIM}Extra usage: $${data.extraSpent} spent${NC}`); } console.log(''); } async function login() { console.error('Opening browser — log in to Claude...'); const browser = await chromium.launch({ headless: false, args: ['--disable-blink-features=AutomationControlled'], }); // Reuse existing session if available, so user doesn't get sent to login page const contextOptions: any = { viewport: { width: 1280, height: 800 } }; if (fs.existsSync(STATE_FILE)) { contextOptions.storageState = STATE_FILE; } const context = await browser.newContext(contextOptions); await context.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); }); const page = await context.newPage(); await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); console.error('Waiting for usage page...'); for (let i = 0; i < 120; i++) { await page.waitForTimeout(2000); const text = await page.evaluate(() => document.body?.innerText || ''); if (text.includes('used') && text.includes('Resets')) { fs.mkdirSync(SESSION_DIR, { recursive: true }); await context.storageState({ path: STATE_FILE }); console.error('Session saved!'); const data = parseUsage(text); renderUsage(data); await browser.close(); return; } } console.error('Timed out.'); await browser.close(); process.exit(1); } async function check() { // Must use headed mode to bypass Cloudflare. // Xvfb creates a truly invisible virtual display — no windows on screen. const display = ':' + (99 + Math.floor(Math.random() * 100)); const xvfb = require('child_process').spawn('Xvfb', [ display, '-screen', '0', '1280x720x24', '-nolisten', 'tcp', ], { stdio: 'ignore', detached: true }); xvfb.unref(); await new Promise(r => setTimeout(r, 500)); const browser = await chromium.launch({ headless: false, args: [ '--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-gpu', ], env: { ...process.env, DISPLAY: display }, }); const origClose = browser.close.bind(browser); (browser as any).close = async () => { await origClose(); try { xvfb.kill('SIGTERM'); } catch {} }; const context = await browser.newContext({ storageState: STATE_FILE, viewport: { width: 1280, height: 720 }, }); await context.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); }); const text = await scrapeUsage(context); await context.close(); await browser.close(); if (!text) { if (jsonMode) { console.log(JSON.stringify({ loggedIn: false })); } else { console.error('Session expired. Run: claude-usage --login'); } process.exit(1); } const data = parseUsage(text); if (jsonMode) { console.log(JSON.stringify({ ...data, loggedIn: true })); } else { renderUsage(data); } } async function live() { // Keep Chromium running, read DOM every 15 seconds, output JSON lines to stdout. // The page updates itself live, so no need to reload. const display = ':' + (99 + Math.floor(Math.random() * 100)); const xvfb = require('child_process').spawn('Xvfb', [ display, '-screen', '0', '1280x720x24', '-nolisten', 'tcp', ], { stdio: 'ignore', detached: true }); xvfb.unref(); await new Promise(r => setTimeout(r, 500)); const browser = await chromium.launch({ headless: false, args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-gpu'], env: { ...process.env, DISPLAY: display }, }); const cleanup = async () => { try { await browser.close(); } catch {} try { xvfb.kill('SIGTERM'); } catch {} process.exit(0); }; process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); const context = await browser.newContext({ storageState: STATE_FILE, viewport: { width: 1280, height: 720 }, }); await context.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); }); const page = await context.newPage(); await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); // Wait for initial load for (let i = 0; i < 20; i++) { await page.waitForTimeout(1000); const t = await page.evaluate(() => document.body?.innerText || ''); if (t.includes('% used')) break; } // Keep session fresh await context.storageState({ path: STATE_FILE }); // Poll DOM every 15 seconds and output JSON lines while (true) { try { const text = await page.evaluate(() => document.body?.innerText || ''); if (text.includes('% used')) { const data = parseUsage(text); console.log(JSON.stringify({ ...data, loggedIn: true })); } else if (text.includes('Log in') || text.includes('Continue with')) { console.log(JSON.stringify({ loggedIn: false })); await cleanup(); } } catch { console.log(JSON.stringify({ loggedIn: false })); await cleanup(); } await page.waitForTimeout(15000); } } // Main const jsonMode = process.argv.includes('--json'); if (process.argv.includes('--login')) { await login(); } else if (process.argv.includes('--live')) { if (!fs.existsSync(STATE_FILE)) { console.log(JSON.stringify({ loggedIn: false })); process.exit(1); } await live(); } else if (fs.existsSync(STATE_FILE)) { await check(); } else { if (jsonMode) { console.log(JSON.stringify({ loggedIn: false })); } else { await login(); } }