mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat: add Claude usage tracking via CLI
Adds a Claude usage tracking feature that displays session, weekly, and Sonnet usage stats. Uses the Claude CLI's /usage command to fetch data (no API key required). Features: - Usage popover in board header showing session, weekly, and Sonnet limits - Progress bars with color-coded status (green/orange/red) - Auto-refresh with configurable interval - Caching of usage data with stale indicator - Settings section for refresh interval configuration Server: - ClaudeUsageService: Executes Claude CLI via PTY (expect) to fetch usage - New /api/claude/usage endpoint UI: - ClaudeUsagePopover component with usage cards - ClaudeUsageSection in settings for configuration - Integration with app store for persistence
This commit is contained in:
@@ -44,6 +44,7 @@ import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
import { SettingsService } from "./services/settings-service.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
||||
import { createClaudeRoutes } from "./routes/claude/index.js";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -141,6 +142,7 @@ app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
app.use("/api/terminal", createTerminalRoutes());
|
||||
app.use("/api/settings", createSettingsRoutes(settingsService));
|
||||
app.use("/api/claude", createClaudeRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
44
apps/server/src/routes/claude/index.ts
Normal file
44
apps/server/src/routes/claude/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { ClaudeUsageService } from "../../services/claude-usage-service.js";
|
||||
|
||||
export function createClaudeRoutes(): Router {
|
||||
const router = Router();
|
||||
const service = new ClaudeUsageService();
|
||||
|
||||
// Get current usage (fetches from Claude CLI)
|
||||
router.get("/usage", async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if Claude CLI is available first
|
||||
const isAvailable = await service.isAvailable();
|
||||
if (!isAvailable) {
|
||||
res.status(503).json({
|
||||
error: "Claude CLI not found",
|
||||
message: "Please install Claude Code CLI and run 'claude login' to authenticate"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = await service.fetchUsageData();
|
||||
res.json(usage);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
if (message.includes("Authentication required") || message.includes("token_expired")) {
|
||||
res.status(401).json({
|
||||
error: "Authentication required",
|
||||
message: "Please run 'claude login' to authenticate"
|
||||
});
|
||||
} else if (message.includes("timed out")) {
|
||||
res.status(504).json({
|
||||
error: "Command timed out",
|
||||
message: "The Claude CLI took too long to respond"
|
||||
});
|
||||
} else {
|
||||
console.error("Error fetching usage:", error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
35
apps/server/src/routes/claude/types.ts
Normal file
35
apps/server/src/routes/claude/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Claude Usage types for CLI-based usage tracking
|
||||
*/
|
||||
|
||||
export type ClaudeUsage = {
|
||||
sessionTokensUsed: number;
|
||||
sessionLimit: number;
|
||||
sessionPercentage: number;
|
||||
sessionResetTime: string; // ISO date string
|
||||
sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)"
|
||||
|
||||
weeklyTokensUsed: number;
|
||||
weeklyLimit: number;
|
||||
weeklyPercentage: number;
|
||||
weeklyResetTime: string; // ISO date string
|
||||
weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)"
|
||||
|
||||
opusWeeklyTokensUsed: number;
|
||||
opusWeeklyPercentage: number;
|
||||
opusResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)"
|
||||
|
||||
costUsed: number | null;
|
||||
costLimit: number | null;
|
||||
costCurrency: string | null;
|
||||
|
||||
lastUpdated: string; // ISO date string
|
||||
userTimezone: string;
|
||||
};
|
||||
|
||||
export type ClaudeStatus = {
|
||||
indicator: {
|
||||
color: "green" | "yellow" | "orange" | "red" | "gray";
|
||||
};
|
||||
description: string;
|
||||
};
|
||||
358
apps/server/src/services/claude-usage-service.ts
Normal file
358
apps/server/src/services/claude-usage-service.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { spawn } from "child_process";
|
||||
import { ClaudeUsage } from "../routes/claude/types.js";
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
* Based on ClaudeBar's implementation approach.
|
||||
*/
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = "claude";
|
||||
private timeout = 30000; // 30 second timeout
|
||||
|
||||
/**
|
||||
* Check if Claude CLI is available on the system
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("which", [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 'expect' to provide a pseudo-TTY since claude requires one
|
||||
*/
|
||||
private executeClaudeUsageCommand(): 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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 opusData = this.parseSection(lines, "Current week (Sonnet only)", "opus");
|
||||
if (opusData.percentage === 0) {
|
||||
opusData = this.parseSection(lines, "Current week (Sonnet)", "opus");
|
||||
}
|
||||
if (opusData.percentage === 0) {
|
||||
opusData = this.parseSection(lines, "Current week (Opus)", "opus");
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
opusWeeklyTokensUsed: 0, // Not available from CLI
|
||||
opusWeeklyPercentage: opusData.percentage,
|
||||
opusResetText: opusData.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 - look for patterns like "65% left" or "35% used"
|
||||
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
|
||||
if (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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user