Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/ccusage/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,45 @@
}
},
"additionalProperties": false
},
"limits": {
"type": "object",
"properties": {
"limit": {
"type": "number",
"description": "Maximum number of events to show (default: 10)",
"markdownDescription": "Maximum number of events to show (default: 10)",
"default": 10
},
"since": {
"type": "string",
"description": "Filter from date (YYYYMMDD format)",
"markdownDescription": "Filter from date (YYYYMMDD format)"
},
"json": {
"type": "boolean",
"description": "Output in JSON format",
"markdownDescription": "Output in JSON format",
"default": false
},
"jq": {
"type": "string",
"description": "Process JSON output with jq command (requires jq binary, implies --json)",
"markdownDescription": "Process JSON output with jq command (requires jq binary, implies --json)"
},
"timezone": {
"type": "string",
"description": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone",
"markdownDescription": "Timezone for date grouping (e.g., UTC, America/New_York, Asia/Tokyo). Default: system timezone"
},
"locale": {
"type": "string",
"description": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)",
"markdownDescription": "Locale for date/time formatting (e.g., en-US, ja-JP, de-DE)",
"default": "en-CA"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
Expand Down
3 changes: 3 additions & 0 deletions apps/ccusage/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cli } from 'gunshi';
import { description, name, version } from '../../package.json';
import { blocksCommand } from './blocks.ts';
import { dailyCommand } from './daily.ts';
import { limitsCommand } from './limits.ts';
import { monthlyCommand } from './monthly.ts';
import { sessionCommand } from './session.ts';
import { statuslineCommand } from './statusline.ts';
Expand All @@ -12,6 +13,7 @@ import { weeklyCommand } from './weekly.ts';
export {
blocksCommand,
dailyCommand,
limitsCommand,
monthlyCommand,
sessionCommand,
statuslineCommand,
Expand All @@ -28,6 +30,7 @@ export const subCommandUnion = [
['session', sessionCommand],
['blocks', blocksCommand],
['statusline', statuslineCommand],
['limits', limitsCommand],
] as const;

/**
Expand Down
326 changes: 326 additions & 0 deletions apps/ccusage/src/commands/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
import { homedir } from 'node:os';
import { join, relative } from 'node:path';
import process from 'node:process';
import { ResponsiveTable } from '@ccusage/terminal/table';
import { Result } from '@praha/byethrow';
import { define } from 'gunshi';
import pc from 'picocolors';
import { glob } from 'tinyglobby';
import * as v from 'valibot';
import { filterByDateRange, sortByDate } from '../_date-utils.ts';
import { processWithJq } from '../_jq-processor.ts';
import { sharedArgs } from '../_shared-args.ts';
import { log, logger } from '../logger.ts';

/**
* Schema for validating the date filter argument
*/
const filterDateSchema = v.pipe(
v.string(),
v.regex(/^\d{8}$/u, 'Date must be in YYYYMMDD format'),
);

/**
* Parses and validates a date argument in YYYYMMDD format
* @param value - Date string to parse
* @returns Validated date string
*/
function parseDateArg(value: string): string {
return v.parse(filterDateSchema, value);
}

/**
* Rate limit event extracted from JSONL logs
*/
type RateLimitEvent = {
/** ISO timestamp of when the limit was hit */
timestamp: string;
/** The reset message from Claude Code */
resetMessage: string;
/** Inferred limit type: 'Weekly' or '5-hour' */
limitType: 'Weekly' | '5-hour';
/** Project path (relative) */
project: string;
/** Session ID */
sessionId: string;
};

/**
* Infers the limit type from the reset message and timestamp
* Weekly limits:
* - Contain a date like "Jan 24", "Feb 5", etc.
* - Contain day references like "Mon at", "tomorrow at"
* - Reset at 6pm on Saturday (when you hit the weekly limit on Saturday)
* 5-hour limits are simpler time-based resets within the same day
*/
function inferLimitType(resetMessage: string, timestamp?: string): 'Weekly' | '5-hour' {
// Weekly patterns:
// - Contains a date like "Jan 24", "Feb 5", etc.
// - Contains day references like "Mon at", "tomorrow at"
const datePattern = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\b/i;
const dayPattern = /\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun|tomorrow)\s+at\b/i;

if (datePattern.test(resetMessage) || dayPattern.test(resetMessage)) {
return 'Weekly';
}

// Check if reset is at 6pm on Saturday - this is the weekly limit reset
// The weekly limit resets at 6pm on Saturdays (Hong Kong time)
if (timestamp != null && resetMessage.includes('6pm')) {
const date = new Date(timestamp);
// Check if it's Saturday (day 6)
// We need to convert to HKT to properly check the day
const hktFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Hong_Kong',
weekday: 'short',
});
const dayInHKT = hktFormatter.format(date);
if (dayInHKT === 'Sat') {
return 'Weekly';
}
}

// Default to 5-hour for simple time patterns
return '5-hour';
}

/**
* Parses a single JSONL line and extracts rate limit event if present
*/
function parseRateLimitEntry(
line: string,
filePath: string,
): RateLimitEvent | null {
try {
const entry = JSON.parse(line) as Record<string, unknown>;

// Check for rate_limit error
if (entry.error !== 'rate_limit') {
return null;
}

// Extract timestamp
const timestamp = entry.timestamp;
if (typeof timestamp !== 'string') {
return null;
}

// Extract the reset message from content
const message = entry.message as Record<string, unknown> | undefined;
const content = message?.content as Array<{ type: string; text?: string }> | undefined;

let resetMessage = '';
if (Array.isArray(content)) {
for (const item of content) {
if (item.type === 'text' && typeof item.text === 'string') {
resetMessage = item.text;
break;
}
}
}

// Extract session ID
const sessionId = entry.sessionId;
if (typeof sessionId !== 'string') {
return null;
}

// Extract project path from file path
const projectsDir = join(homedir(), '.claude', 'projects');
const projectPath = relative(projectsDir, filePath);
const project = projectPath.split('/')[0] ?? 'unknown';

Comment on lines +128 to +132
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a path-separator-agnostic split for project name.

relative() returns backslashes on Windows, so splitting only on / makes the project name incorrect there.

🔧 Suggested fix
-		const project = projectPath.split('/')[0] ?? 'unknown';
+		const project = projectPath.split(/[/\\]/u)[0] ?? "unknown";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Extract project path from file path
const projectsDir = join(homedir(), '.claude', 'projects');
const projectPath = relative(projectsDir, filePath);
const project = projectPath.split('/')[0] ?? 'unknown';
// Extract project path from file path
const projectsDir = join(homedir(), '.claude', 'projects');
const projectPath = relative(projectsDir, filePath);
const project = projectPath.split(/[/\\]/u)[0] ?? "unknown";
🤖 Prompt for AI Agents
In `@apps/ccusage/src/commands/limits.ts` around lines 128 - 132, The project name
extraction is Windows-broken because projectPath.split('/') only handles POSIX
separators; change the split to be path-separator-agnostic by using Node's path
separator (path.sep) or a cross-platform regex (e.g., split on both '/' and
'\\') when deriving project from projectPath (the variables to update are
projectsDir, projectPath and the project assignment that uses
projectPath.split('/')[0]); ensure you import or reference path.sep if using
that approach so the code works correctly on Windows and POSIX.

return {
timestamp,
resetMessage,
limitType: inferLimitType(resetMessage, timestamp),
project,
sessionId,
};
} catch {
return null;
}
}

/**
* Scans all JSONL files for rate limit events
*/
async function scanForRateLimits(): Promise<RateLimitEvent[]> {
const projectsDir = join(homedir(), '.claude', 'projects');
const pattern = join(projectsDir, '*', '*.jsonl');

const files = await glob(pattern, { onlyFiles: true });
const events: RateLimitEvent[] = [];

for (const filePath of files) {
const { createReadStream } = await import('node:fs');
const { createInterface } = await import('node:readline');

Comment on lines +155 to +158
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/ccusage/src/commands/limits.ts | head -180 | tail -60

Repository: ryoppippi/ccusage

Length of output: 1889


🏁 Script executed:

# Also check the exact content at lines 155-158
sed -n '1,10p' apps/ccusage/src/commands/limits.ts && echo "---" && sed -n '150,160p' apps/ccusage/src/commands/limits.ts

Repository: ryoppippi/ccusage

Length of output: 854


🏁 Script executed:

# Check for other await import() usage in the file
rg -n "await\s+import\(" apps/ccusage/src/commands/limits.ts

Repository: ryoppippi/ccusage

Length of output: 185


🏁 Script executed:

# Check how line 140 parseRateLimitEntry function is structured and other try/catch blocks
sed -n '95,145p' apps/ccusage/src/commands/limits.ts

Repository: ryoppippi/ccusage

Length of output: 1256


🏁 Script executed:

# Check if there are other violations in the file
rg -n "try\s*\{" apps/ccusage/src/commands/limits.ts

Repository: ryoppippi/ccusage

Length of output: 70


Remove dynamic imports and refactor error handling to use Result.try().

Dynamic await import() statements at lines 156-157 violate the tree-shaking policy and must be removed. Additionally, the try/catch block wrapping JSON.parse() (lines 94-142) should use Result.try() instead, per the @praha/byethrow error handling guidelines.

Move createReadStream and createInterface to the static imports at the top of the file, then reuse them inside the loop. For the JSON parsing at line 95, wrap it with Result.try() and handle the error result instead of using catch.

🔧 Suggested fix
+import { createReadStream } from "node:fs";
 import { homedir } from "node:os";
 import { join, relative } from "node:path";
 import process from "node:process";
+import { createInterface } from "node:readline";
-	try {
-		const entry = JSON.parse(line) as Record<string, unknown>;
-		// ... rest of parsing logic
-	} catch {
-		return null;
-	}
+	const parseResult = Result.try(() => JSON.parse(line) as Record<string, unknown>);
+	if (Result.isFailure(parseResult)) {
+		return null;
+	}
+	const entry = parseResult.value;
+	// ... rest of parsing logic
-	for (const filePath of files) {
-		const { createReadStream } = await import('node:fs');
-		const { createInterface } = await import('node:readline');
-
 		const fileStream = createReadStream(filePath);
🤖 Prompt for AI Agents
In `@apps/ccusage/src/commands/limits.ts` around lines 155 - 158, Remove the
dynamic imports inside the loop by adding static imports for createReadStream
and createInterface at the top of the module and then reuse those identifiers
inside the for (const filePath of files) loop (replace the await
import('node:fs') / await import('node:readline') usage). Replace the try/catch
around JSON.parse(...) with `@praha/byethrow`'s Result.try(): call Result.try(()
=> JSON.parse(someString)) where JSON.parse is currently used, then handle the
Result returned (check .isErr()/.unwrap() or pattern-match) instead of the catch
block; keep existing variable names and surrounding logic intact so callers like
the code that reads lines and uses the parsed object continue to work with the
unwrapped value.

const fileStream = createReadStream(filePath);
const rl = createInterface({
input: fileStream,
crlfDelay: Number.POSITIVE_INFINITY,
});

for await (const line of rl) {
if (line.includes('"error":"rate_limit"')) {
const event = parseRateLimitEntry(line, filePath);
Comment on lines +165 to +167
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid missing rate-limit entries with whitespace in JSON.

The exact string check skips lines like "error": "rate_limit". Use a whitespace-tolerant regex or parse every line.

🔧 Suggested fix
-			if (line.includes('"error":"rate_limit"')) {
+			if (/"error"\s*:\s*"rate_limit"/u.test(line)) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for await (const line of rl) {
if (line.includes('"error":"rate_limit"')) {
const event = parseRateLimitEntry(line, filePath);
for await (const line of rl) {
if (/"error"\s*:\s*"rate_limit"/u.test(line)) {
const event = parseRateLimitEntry(line, filePath);
🤖 Prompt for AI Agents
In `@apps/ccusage/src/commands/limits.ts` around lines 165 - 167, The current loop
over rl checks for the exact substring '"error":"rate_limit"' which misses
variants with whitespace; update the check in the for-await loop that calls
parseRateLimitEntry(line, filePath) to be whitespace-tolerant (e.g., use a regex
like /"error"\s*:\s*"rate_limit"/) or attempt JSON.parse(line) and test
parsed.error === 'rate_limit' before calling parseRateLimitEntry; ensure the
change is applied where rl is iterated and preserves passing the original line
and filePath to parseRateLimitEntry.

if (event != null) {
events.push(event);
}
}
}
}

return events;
}

/**
* Formats a timestamp to local date/time string
*/
function formatLocalDateTime(
timestamp: string,
timezone?: string,
locale?: string,
): string {
const date = new Date(timestamp);
const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timezone,
});
return formatter.format(date);
}

/**
* Extracts the reset time portion from the full reset message
*/
function extractResetTime(resetMessage: string): string {
// Remove "You've hit your limit · resets " prefix if present
const match = resetMessage.match(/resets?\s+(.+)$/i);
if (match?.[1] != null) {
return match[1];
}
return resetMessage;
}

export const limitsCommand = define({
name: 'limits',
description: 'Show historical rate limit events from Claude Code logs',
toKebab: true,
args: {
limit: {
type: 'number',
short: 'n',
description: 'Maximum number of events to show (default: 10)',
default: 10,
},
since: {
type: 'custom',
short: 's',
description: 'Filter from date (YYYYMMDD format)',
parse: parseDateArg,
},
json: {
type: 'boolean',
short: 'j',
description: 'Output in JSON format',
default: false,
},
jq: sharedArgs.jq,
timezone: sharedArgs.timezone,
locale: sharedArgs.locale,
},
async run(ctx) {
// --jq implies --json
const useJson = Boolean(ctx.values.json) || ctx.values.jq != null;
if (useJson) {
logger.level = 0;
}

// Scan for rate limit events
const allEvents = await scanForRateLimits();

if (allEvents.length === 0) {
if (useJson) {
log(JSON.stringify({ events: [], total: 0 }));
} else {
logger.info('No rate limit events found.');
}
process.exit(0);
}

// Filter by date if specified
let filteredEvents = ctx.values.since != null
? filterByDateRange(allEvents, (e) => e.timestamp, ctx.values.since)
: allEvents;

// Sort by timestamp (newest first)
filteredEvents = sortByDate(filteredEvents, (e) => e.timestamp, 'desc');

// Apply limit
const limitedEvents = filteredEvents.slice(0, ctx.values.limit);

if (useJson) {
const jsonOutput = {
events: limitedEvents.map((e) => ({
hitTime: e.timestamp,
resetTime: extractResetTime(e.resetMessage),
type: e.limitType,
project: e.project,
sessionId: e.sessionId,
})),
total: filteredEvents.length,
showing: limitedEvents.length,
};

if (ctx.values.jq != null) {
const jqResult = await processWithJq(jsonOutput, ctx.values.jq);
if (Result.isFailure(jqResult)) {
logger.error(jqResult.error.message);
process.exit(1);
}
log(jqResult.value);
} else {
log(JSON.stringify(jsonOutput, null, 2));
}
} else {
// Print header
logger.box('Claude Code Rate Limit History');

// Build table using ResponsiveTable
const table = new ResponsiveTable({
head: ['Hit Time', 'Reset Time', 'Type'],
style: { head: ['cyan'] },
colAligns: ['left', 'left', 'left'],
});

for (const event of limitedEvents) {
const hitTime = formatLocalDateTime(
event.timestamp,
ctx.values.timezone,
ctx.values.locale,
);
const resetTime = extractResetTime(event.resetMessage);
const typeColor = event.limitType === 'Weekly' ? pc.yellow : pc.green;

table.push([hitTime, resetTime, typeColor(event.limitType)]);
}

log(table.toString());

// Show summary
if (filteredEvents.length > limitedEvents.length) {
logger.info(
`\nShowing ${limitedEvents.length} of ${filteredEvents.length} events. Use --limit to see more.`,
);
} else {
logger.info(`\nTotal: ${filteredEvents.length} rate limit events.`);
}
}
},
});