import { spawn } from 'child_process'; import * as os from 'os'; import * as pty from 'node-pty'; import { ClaudeUsage } from '../routes/claude/types.js'; import { createLogger } from '@automaker/utils'; /** * Claude Usage Service * * Fetches usage data by executing the Claude CLI's /usage command. * This approach doesn't require any API keys - it relies on the user * having already authenticated via `claude login`. * * Platform-specific implementations: * - macOS: Uses 'expect' command for PTY * - Windows/Linux: Uses node-pty for PTY */ const logger = createLogger('ClaudeUsage'); export class ClaudeUsageService { private claudeBinary = 'claude'; private timeout = 30000; // 30 second timeout private isWindows = os.platform() === 'win32'; private isLinux = os.platform() === 'linux'; // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode // Detect Electron by checking for electron-specific env vars or process properties // When in Electron, always use winpty to avoid ConPTY's AttachConsole errors private isElectron = !!(process.versions && (process.versions as Record).electron) || !!process.env.ELECTRON_RUN_AS_NODE; private useConptyFallback = false; // Track if we need to use winpty fallback on Windows /** * Kill a PTY process with platform-specific handling. * Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments. * On Unix-like systems (macOS, Linux), we can specify the signal. * * @param ptyProcess - The PTY process to kill * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') */ private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { if (this.isWindows) { ptyProcess.kill(); } else { ptyProcess.kill(signal); } } /** * Check if Claude CLI is available on the system */ async isAvailable(): Promise { return new Promise((resolve) => { const checkCmd = this.isWindows ? 'where' : 'which'; const proc = spawn(checkCmd, [this.claudeBinary]); proc.on('close', (code) => { resolve(code === 0); }); proc.on('error', () => { resolve(false); }); }); } /** * Fetch usage data by executing the Claude CLI */ async fetchUsageData(): Promise { const output = await this.executeClaudeUsageCommand(); return this.parseUsageOutput(output); } /** * Execute the claude /usage command and return the output * Uses node-pty on all platforms for consistency */ private executeClaudeUsageCommand(): Promise { // Use node-pty on all platforms - it's more reliable than expect on macOS return this.executeClaudeUsageCommandPty(); } /** * macOS implementation using 'expect' command */ private executeClaudeUsageCommandMac(): Promise { return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let settled = false; // Use current working directory - likely already trusted by Claude CLI const workingDirectory = process.cwd(); // Use 'expect' with an inline script to run claude /usage with a PTY // Running from cwd which should already be trusted const expectScript = ` set timeout 30 spawn claude /usage # Wait for usage data or handle trust prompt if needed expect { -re "Ready to code|permission to work|Do you want to work" { # Trust prompt appeared - send Enter to approve sleep 1 send "\\r" exp_continue } "Current session" { # Usage data appeared - wait for full output, then exit sleep 3 send "\\x1b" } "% left" { # Usage percentage appeared sleep 3 send "\\x1b" } timeout { send "\\x1b" } eof {} } expect eof `; const proc = spawn('expect', ['-c', expectScript], { cwd: workingDirectory, env: { ...process.env, TERM: 'xterm-256color', }, }); const timeoutId = setTimeout(() => { if (!settled) { settled = true; proc.kill(); reject(new Error('Command timed out')); } }, this.timeout); proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('close', (code) => { clearTimeout(timeoutId); if (settled) return; settled = true; // Check for authentication errors in output if ( stdout.includes('token_expired') || stdout.includes('authentication_error') || stderr.includes('token_expired') || stderr.includes('authentication_error') ) { reject(new Error("Authentication required - please run 'claude login'")); return; } // Even if exit code is non-zero, we might have useful output if (stdout.trim()) { resolve(stdout); } else if (code !== 0) { reject(new Error(stderr || `Command exited with code ${code}`)); } else { reject(new Error('No output from claude command')); } }); proc.on('error', (err) => { clearTimeout(timeoutId); if (!settled) { settled = true; reject(new Error(`Failed to execute claude: ${err.message}`)); } }); }); } /** * Windows/Linux implementation using node-pty */ private executeClaudeUsageCommandPty(): Promise { return new Promise((resolve, reject) => { let output = ''; let settled = false; let hasSeenUsageData = false; let hasSeenTrustPrompt = false; // Use current working directory (project dir) - most likely already trusted by Claude CLI const workingDirectory = process.cwd(); // Use platform-appropriate shell and command const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; // Use --add-dir to whitelist the current directory and bypass the trust prompt // We don't pass /usage here, we'll type it into the REPL const args = this.isWindows ? ['/c', 'claude', '--add-dir', workingDirectory] : ['-c', `claude --add-dir "${workingDirectory}"`]; // Using 'any' for ptyProcess because node-pty types don't include 'killed' property // eslint-disable-next-line @typescript-eslint/no-explicit-any let ptyProcess: any = null; // Build PTY spawn options const ptyOptions: pty.IPtyForkOptions = { name: 'xterm-256color', cols: 120, rows: 30, cwd: workingDirectory, env: { ...process.env, TERM: 'xterm-256color', } as Record, }; // On Windows, always use winpty instead of ConPTY // ConPTY requires AttachConsole which fails in many contexts: // - Electron apps without a console // - VS Code integrated terminal // - Spawned from other applications // The error happens in a subprocess so we can't catch it - must proactively disable if (this.isWindows) { (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; logger.info( '[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)' ); } try { ptyProcess = pty.spawn(shell, args, ptyOptions); } catch (spawnError) { const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); // Check for Windows ConPTY-specific errors if (this.isWindows && errorMessage.includes('AttachConsole failed')) { // ConPTY failed - try winpty fallback if (!this.useConptyFallback) { logger.warn( '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback' ); this.useConptyFallback = true; try { (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; ptyProcess = pty.spawn(shell, args, ptyOptions); logger.info( '[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback' ); } catch (fallbackError) { const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); logger.error( '[executeClaudeUsageCommandPty] Winpty fallback also failed:', fallbackMessage ); reject( new Error( `Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}` ) ); return; } } else { logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage); reject( new Error( `Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.` ) ); return; } } else { logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); reject( new Error( `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` ) ); return; } } const timeoutId = setTimeout(() => { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { this.killPtyProcess(ptyProcess); } // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); } else if (hasSeenTrustPrompt) { // Trust prompt was shown but we couldn't auto-approve it reject( new Error( 'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.' ) ); } else { reject( new Error( 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' ) ); } } }, 45000); // 45 second timeout let hasSentCommand = false; let hasApprovedTrust = false; ptyProcess.onData((data: string) => { output += data; // Strip ANSI codes for easier matching // eslint-disable-next-line no-control-regex const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); // Check for specific authentication/permission errors // Must be very specific to avoid false positives from garbled terminal encoding // Removed permission_error check as it was causing false positives with winpty encoding const authChecks = { oauth: cleanOutput.includes('OAuth token does not meet scope requirement'), tokenExpired: cleanOutput.includes('token_expired'), // Only match if it looks like a JSON API error response authError: cleanOutput.includes('"type":"authentication_error"') || cleanOutput.includes('"type": "authentication_error"'), }; const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError; if (hasAuthError) { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { this.killPtyProcess(ptyProcess); } reject( new Error( "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." ) ); } return; } // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) // Also check for percentage patterns that appear in usage output const hasUsageIndicators = cleanOutput.includes('Current session') || (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || // Additional patterns for winpty - look for percentage patterns /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || cleanOutput.includes('Resets in') || cleanOutput.includes('Current week'); if (!hasSeenUsageData && hasUsageIndicators) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s // Windows doesn't support signals, so killPtyProcess handles platform differences setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { this.killPtyProcess(ptyProcess); } }, 2000); } }, 3000); } // Handle Trust Dialog - multiple variants: // - "Do you want to work in this folder?" // - "Ready to code here?" / "I'll need permission to work with your files" // Since we are running in cwd (project dir), it is safe to approve. if ( !hasApprovedTrust && (cleanOutput.includes('Do you want to work in this folder?') || cleanOutput.includes('Ready to code here') || cleanOutput.includes('permission to work with your files')) ) { hasApprovedTrust = true; hasSeenTrustPrompt = true; // Wait a tiny bit to ensure prompt is ready, then send Enter setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\r'); } }, 1000); } // Detect REPL prompt and send /usage command // On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators const isReplReady = cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts') || // Fallback for winpty garbled encoding - detect CLI welcome screen elements (cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) || (cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) || // Detect model indicator which appears when REPL is ready (cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) || (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')); if (!hasSentCommand && isReplReady) { hasSentCommand = true; // Wait for REPL to fully settle setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { // Send command with carriage return ptyProcess.write('/usage\r'); // Send another enter after 1 second to confirm selection if autocomplete menu appeared setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\r'); } }, 1200); } }, 1500); } // Fallback: if we see "Esc to cancel" but haven't seen usage data yet if ( !hasSeenUsageData && cleanOutput.includes('Esc to cancel') && !cleanOutput.includes('Do you want to work in this folder?') ) { setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } }, 5000); } }); ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { clearTimeout(timeoutId); if (settled) return; settled = true; // Check for auth errors - must be specific to avoid false positives // Removed permission_error check as it was causing false positives with winpty encoding if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) { reject(new Error("Authentication required - please run 'claude login'")); return; } if (output.trim()) { resolve(output); } else if (exitCode !== 0) { reject(new Error(`Command exited with code ${exitCode}`)); } else { reject(new Error('No output from claude command')); } }); }); } /** * Strip ANSI escape codes from text */ private stripAnsiCodes(text: string): string { // eslint-disable-next-line no-control-regex return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); } /** * Parse the Claude CLI output to extract usage information * * Expected output format: * ``` * Claude Code v1.0.27 * * Current session * ████████████████░░░░ 65% left * Resets in 2h 15m * * Current week (all models) * ██████████░░░░░░░░░░ 35% left * Resets Jan 15, 3:30pm (America/Los_Angeles) * * Current week (Opus) * ████████████████████ 80% left * Resets Jan 15, 3:30pm (America/Los_Angeles) * ``` */ private parseUsageOutput(rawOutput: string): ClaudeUsage { const output = this.stripAnsiCodes(rawOutput); const lines = output .split('\n') .map((l) => l.trim()) .filter((l) => l); // Parse session usage const sessionData = this.parseSection(lines, 'Current session', 'session'); // Parse weekly usage (all models) const weeklyData = this.parseSection(lines, 'Current week (all models)', 'weekly'); // Parse Sonnet/Opus usage - try different labels let sonnetData = this.parseSection(lines, 'Current week (Sonnet only)', 'sonnet'); if (sonnetData.percentage === 0) { sonnetData = this.parseSection(lines, 'Current week (Sonnet)', 'sonnet'); } if (sonnetData.percentage === 0) { sonnetData = this.parseSection(lines, 'Current week (Opus)', 'sonnet'); } return { sessionTokensUsed: 0, // Not available from CLI sessionLimit: 0, // Not available from CLI sessionPercentage: sessionData.percentage, sessionResetTime: sessionData.resetTime, sessionResetText: sessionData.resetText, weeklyTokensUsed: 0, // Not available from CLI weeklyLimit: 0, // Not available from CLI weeklyPercentage: weeklyData.percentage, weeklyResetTime: weeklyData.resetTime, weeklyResetText: weeklyData.resetText, sonnetWeeklyTokensUsed: 0, // Not available from CLI sonnetWeeklyPercentage: sonnetData.percentage, sonnetResetText: sonnetData.resetText, costUsed: null, // Not available from CLI costLimit: null, costCurrency: null, lastUpdated: new Date().toISOString(), userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }; } /** * Parse a section of the usage output to extract percentage and reset time */ private parseSection( lines: string[], sectionLabel: string, type: string ): { percentage: number; resetTime: string; resetText: string } { let percentage = 0; let resetTime = this.getDefaultResetTime(type); let resetText = ''; // Find the LAST occurrence of the section (terminal output has multiple screen refreshes) let sectionIndex = -1; for (let i = lines.length - 1; i >= 0; i--) { if (lines[i].toLowerCase().includes(sectionLabel.toLowerCase())) { sectionIndex = i; break; } } if (sectionIndex === -1) { return { percentage, resetTime, resetText }; } // Look at the lines following the section header (within a window of 5 lines) const searchWindow = lines.slice(sectionIndex, sectionIndex + 5); for (const line of searchWindow) { // Extract percentage - only take the first match (avoid picking up next section's data) if (percentage === 0) { const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); if (percentMatch) { const value = parseInt(percentMatch[1], 10); const isUsed = percentMatch[2].toLowerCase() === 'used'; // Convert "left" to "used" percentage (our UI shows % used) percentage = isUsed ? value : 100 - value; } } // Extract reset time - only take the first match if (!resetText && line.toLowerCase().includes('reset')) { resetText = line; } } // Parse the reset time if we found one if (resetText) { resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); } return { percentage, resetTime, resetText }; } /** * Parse reset time from text like "Resets in 2h 15m", "Resets 11am", or "Resets Dec 22 at 8pm" */ private parseResetTime(text: string, type: string): string { const now = new Date(); // Try to parse duration format: "Resets in 2h 15m" or "Resets in 30m" const durationMatch = text.match( /(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i ); if (durationMatch) { let hours = 0; let minutes = 0; if (durationMatch[1]) { hours = parseInt(durationMatch[1], 10); minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; } else if (durationMatch[3]) { minutes = parseInt(durationMatch[3], 10); } const resetDate = new Date(now.getTime() + (hours * 60 + minutes) * 60 * 1000); return resetDate.toISOString(); } // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); if (simpleTimeMatch) { let hours = parseInt(simpleTimeMatch[1], 10); const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; const ampm = simpleTimeMatch[3].toLowerCase(); // Convert 12-hour to 24-hour if (ampm === 'pm' && hours !== 12) { hours += 12; } else if (ampm === 'am' && hours === 12) { hours = 0; } // Create date for today at specified time const resetDate = new Date(now); resetDate.setHours(hours, minutes, 0, 0); // If time has passed, use tomorrow if (resetDate <= now) { resetDate.setDate(resetDate.getDate() + 1); } return resetDate.toISOString(); } // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" const dateMatch = text.match( /([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i ); if (dateMatch) { const monthName = dateMatch[1]; const day = parseInt(dateMatch[2], 10); let hours = parseInt(dateMatch[3], 10); const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0; const ampm = dateMatch[5].toLowerCase(); // Convert 12-hour to 24-hour if (ampm === 'pm' && hours !== 12) { hours += 12; } else if (ampm === 'am' && hours === 12) { hours = 0; } // Parse month name const months: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, }; const month = months[monthName.toLowerCase().substring(0, 3)]; if (month !== undefined) { let year = now.getFullYear(); // If the date appears to be in the past, assume next year const resetDate = new Date(year, month, day, hours, minutes); if (resetDate < now) { resetDate.setFullYear(year + 1); } return resetDate.toISOString(); } } // Fallback to default return this.getDefaultResetTime(type); } /** * Get default reset time based on usage type */ private getDefaultResetTime(type: string): string { const now = new Date(); if (type === 'session') { // Session resets in ~5 hours return new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString(); } else { // Weekly resets on next Monday around noon const result = new Date(now); const currentDay = now.getDay(); let daysUntilMonday = (1 + 7 - currentDay) % 7; if (daysUntilMonday === 0) daysUntilMonday = 7; result.setDate(result.getDate() + daysUntilMonday); result.setHours(12, 59, 0, 0); return result.toISOString(); } } }