mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
- Add try-catch around pty.spawn() to prevent crashes when PTY unavailable - Add unhandledRejection/uncaughtException handlers for graceful degradation - Add checkBackendHealth/waitForBackendHealth utilities for tests - Add data/.api-key and data/credentials.json to .gitignore
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* Check if Claude CLI is available on the system
|
|
*/
|
|
async isAvailable(): Promise<boolean> {
|
|
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<ClaudeUsage> {
|
|
const output = await this.executeClaudeUsageCommand();
|
|
return this.parseUsageOutput(output);
|
|
}
|
|
|
|
/**
|
|
* Execute the claude /usage command and return the output
|
|
* Uses platform-specific PTY implementation
|
|
*/
|
|
private executeClaudeUsageCommand(): Promise<string> {
|
|
if (this.isWindows || this.isLinux) {
|
|
return this.executeClaudeUsageCommandPty();
|
|
}
|
|
return this.executeClaudeUsageCommandMac();
|
|
}
|
|
|
|
/**
|
|
* macOS implementation using 'expect' command
|
|
*/
|
|
private executeClaudeUsageCommandMac(): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let settled = false;
|
|
|
|
// Use a simple working directory (home or tmp)
|
|
const workingDirectory = process.env.HOME || '/tmp';
|
|
|
|
// Use 'expect' with an inline script to run claude /usage with a PTY
|
|
// Wait for "Current session" header, then wait for full output before exiting
|
|
const expectScript = `
|
|
set timeout 20
|
|
spawn claude /usage
|
|
expect {
|
|
"Current session" {
|
|
sleep 2
|
|
send "\\x1b"
|
|
}
|
|
"Esc to cancel" {
|
|
sleep 3
|
|
send "\\x1b"
|
|
}
|
|
timeout {}
|
|
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<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let output = '';
|
|
let settled = false;
|
|
let hasSeenUsageData = false;
|
|
|
|
const workingDirectory = this.isWindows
|
|
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
|
: process.env.HOME || os.homedir() || '/tmp';
|
|
|
|
// Use platform-appropriate shell and command
|
|
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
|
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
|
|
|
let ptyProcess: any = null;
|
|
|
|
try {
|
|
ptyProcess = pty.spawn(shell, args, {
|
|
name: 'xterm-256color',
|
|
cols: 120,
|
|
rows: 30,
|
|
cwd: workingDirectory,
|
|
env: {
|
|
...process.env,
|
|
TERM: 'xterm-256color',
|
|
} as Record<string, string>,
|
|
});
|
|
} catch (spawnError) {
|
|
// pty.spawn() can throw synchronously if the native module fails to load
|
|
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
|
|
|
// Return a user-friendly error instead of crashing
|
|
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) {
|
|
ptyProcess.kill();
|
|
}
|
|
// Don't fail if we have data - return it instead
|
|
if (output.includes('Current session')) {
|
|
resolve(output);
|
|
} else {
|
|
reject(new Error('Command timed out'));
|
|
}
|
|
}
|
|
}, this.timeout);
|
|
|
|
ptyProcess.onData((data: string) => {
|
|
output += data;
|
|
|
|
// Check if we've seen the usage data (look for "Current session")
|
|
if (!hasSeenUsageData && output.includes('Current session')) {
|
|
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
|
|
setTimeout(() => {
|
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
|
ptyProcess.kill('SIGTERM');
|
|
}
|
|
}, 2000);
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
|
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
|
setTimeout(() => {
|
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
|
ptyProcess.write('\x1b'); // Send escape key
|
|
}
|
|
}, 3000);
|
|
}
|
|
});
|
|
|
|
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
|
clearTimeout(timeoutId);
|
|
if (settled) return;
|
|
settled = true;
|
|
|
|
// Check for authentication errors in output
|
|
if (output.includes('token_expired') || output.includes('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<string, number> = {
|
|
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();
|
|
}
|
|
}
|
|
}
|