Skip to content

Commit 9d94d54

Browse files
duyetduyetbot
andcommitted
feat(statusline): add 5h/7d rate limit tracking
- Add fetch-rate-limits.sh script to fetch usage from Anthropic API - Support macOS Keychain for OAuth token retrieval - Fall back to file-based credentials on Linux - Show "re-login needed" when OAuth scope is insufficient - Display rate limits in compact format: "5h: X% | 7d: Y%" Co-Authored-By: duyetbot <duyetbot@users.noreply.github.com>
1 parent a17da44 commit 9d94d54

3 files changed

Lines changed: 117 additions & 1 deletion

File tree

statusline/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "statusline",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "Real-time visibility into Claude Code sessions showing context usage, active tools, and task progress in compact one-line format",
55
"author": {
66
"name": "duyet"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/bin/bash
2+
# Fetch rate limits from Anthropic OAuth API
3+
# Outputs JSON: {"five_hour": N, "seven_day": N, "resets_at": "ISO", "error": "..."}
4+
5+
token=""
6+
7+
# macOS Keychain (primary on macOS)
8+
if [ "$(uname)" = "Darwin" ]; then
9+
keychain_data=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null)
10+
if [ -n "$keychain_data" ]; then
11+
token=$(echo "$keychain_data" | jq -r '.claudeAiOauth.accessToken // empty' 2>/dev/null)
12+
fi
13+
fi
14+
15+
# Fall back to file-based credentials
16+
if [ -z "$token" ] || [ "$token" = "null" ]; then
17+
for cred_path in \
18+
~/.claude/.credentials.json \
19+
~/.config/claude/.credentials.json \
20+
~/.config/claude-code/.credentials.json
21+
do
22+
if [ -f "$cred_path" ]; then
23+
token=$(jq -r '.claudeAiOauth.accessToken // empty' "$cred_path" 2>/dev/null)
24+
if [ -n "$token" ] && [ "$token" != "null" ]; then
25+
break
26+
fi
27+
fi
28+
done
29+
fi
30+
31+
if [ -z "$token" ] || [ "$token" = "null" ]; then
32+
echo '{"error": "no_credentials"}'
33+
exit 0
34+
fi
35+
36+
# Fetch from API
37+
api_response=$(curl -s -m 3 \
38+
-H "Authorization: Bearer $token" \
39+
-H "anthropic-beta: oauth-2025-04-20" \
40+
"https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
41+
42+
if [ -z "$api_response" ]; then
43+
echo '{"error": "api_timeout"}'
44+
exit 0
45+
fi
46+
47+
# Check for error
48+
if echo "$api_response" | jq -e '.error' > /dev/null 2>&1; then
49+
error_msg=$(echo "$api_response" | jq -r '.error.message // "unknown"')
50+
if echo "$error_msg" | grep -qi "scope"; then
51+
echo '{"error": "scope_required"}'
52+
else
53+
echo '{"error": "api_error"}'
54+
fi
55+
exit 0
56+
fi
57+
58+
# Parse and output
59+
u5h=$(echo "$api_response" | jq -r '.five_hour.utilization // 0')
60+
u7d=$(echo "$api_response" | jq -r '.seven_day.utilization // 0')
61+
r5h=$(echo "$api_response" | jq -r '.five_hour.resets_at // empty')
62+
63+
printf '{"five_hour": %s, "seven_day": %s, "resets_at": "%s"}\n' \
64+
"${u5h:-0}" "${u7d:-0}" "${r5h:-}"

statusline/scripts/format-status.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
/**
22
* Compact one-line status formatter for Claude Code sessions
33
* Hides empty values, shows only relevant metrics
4+
* Includes rate limit tracking (5h/7d)
45
*/
56

7+
import { execFileSync } from "child_process";
8+
import { dirname, join } from "path";
9+
import { fileURLToPath } from "url";
10+
11+
interface RateLimits {
12+
five_hour: number;
13+
seven_day: number;
14+
resets_at?: string;
15+
error?: string;
16+
}
17+
618
interface SessionMetrics {
719
context?: {
820
used: number;
@@ -20,6 +32,7 @@ interface SessionMetrics {
2032
};
2133
duration?: string;
2234
systemPrompts?: string[];
35+
rateLimits?: RateLimits;
2336
}
2437

2538
interface FormattedStatus {
@@ -89,13 +102,52 @@ function formatDuration(duration?: string): string | null {
89102
return `${duration}`;
90103
}
91104

105+
function formatRateLimits(rateLimits?: RateLimits): string | null {
106+
if (!rateLimits) return null;
107+
108+
if (rateLimits.error === "scope_required") {
109+
return "5h: re-login | 7d: needed";
110+
}
111+
if (rateLimits.error) {
112+
return null; // Hide on other errors
113+
}
114+
115+
const u5h = Math.round(rateLimits.five_hour);
116+
const u7d = Math.round(rateLimits.seven_day);
117+
118+
return `5h: ${u5h}% | 7d: ${u7d}%`;
119+
}
120+
121+
function fetchRateLimits(): RateLimits | null {
122+
try {
123+
const __filename = fileURLToPath(import.meta.url);
124+
const __dirname = dirname(__filename);
125+
const scriptPath = join(__dirname, "fetch-rate-limits.sh");
126+
127+
// Use execFileSync for safety (no shell injection)
128+
const result = execFileSync("bash", [scriptPath], {
129+
encoding: "utf-8",
130+
timeout: 5000,
131+
});
132+
133+
return JSON.parse(result.trim()) as RateLimits;
134+
} catch {
135+
return null;
136+
}
137+
}
138+
92139
export function formatStatus(metrics: SessionMetrics): FormattedStatus {
93140
const parts: string[] = [];
94141

95142
// Add context health first (most important)
96143
const contextStr = formatContext(metrics);
97144
if (contextStr) parts.push(contextStr);
98145

146+
// Add rate limits (5h/7d usage)
147+
const rateLimits = metrics.rateLimits ?? fetchRateLimits();
148+
const rateLimitsStr = formatRateLimits(rateLimits);
149+
if (rateLimitsStr) parts.push(rateLimitsStr);
150+
99151
// Add model (optional)
100152
const modelStr = formatModel(metrics.model);
101153
if (modelStr) parts.push(modelStr);

0 commit comments

Comments
 (0)