324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
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<string | null> {
|
|
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<string, any> = {};
|
|
|
|
// 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<string, any>) {
|
|
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();
|
|
}
|
|
}
|