cc-costline 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/statusline.js +49 -29
  2. package/package.json +1 -1
@@ -4,8 +4,10 @@ import { homedir } from "node:os";
4
4
  import { execSync } from "node:child_process";
5
5
  import { readCache, writeCache, readConfig } from "./cache.js";
6
6
  import { collectCosts } from "./collector.js";
7
- // Unified TTL for all cached data (2 minutes)
7
+ // TTL for local cost cache (2 minutes)
8
8
  const CACHE_TTL_MS = 120_000;
9
+ // TTL for external API retry throttle (5 minutes) — usage API has strict per-token rate limits (~5 req/token)
10
+ const API_RETRY_TTL_MS = 300_000;
9
11
  // ANSI colors (matching original statusline.sh)
10
12
  const FG_GRAY = "\x1b[38;5;245m";
11
13
  const FG_GRAY_DIM = "\x1b[38;5;102m";
@@ -65,23 +67,29 @@ export function shouldRefreshLocalCostCache(cache, transcriptPath = "", now = Da
65
67
  }
66
68
  return now - cacheUpdatedAt >= CACHE_TTL_MS;
67
69
  }
68
- // ccclub rank fetcher — TTL-based cache (stale fallback on failure)
70
+ // ccclub rank fetcher — split cache: data persists, retry throttled
69
71
  function getCcclubRank() {
70
72
  const configPath = join(homedir(), ".ccclub", "config.json");
71
73
  if (!existsSync(configPath))
72
74
  return null;
73
75
  const cacheFile = "/tmp/sl-ccclub-rank";
76
+ const now = Date.now();
74
77
  let staleData = null;
78
+ let lastAttempt = 0;
75
79
  if (existsSync(cacheFile)) {
76
80
  try {
77
81
  const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
78
82
  staleData = cached.data ?? null;
79
- const cacheAge = Date.now() - (cached.timestamp || 0);
80
- if (cacheAge < CACHE_TTL_MS)
83
+ lastAttempt = cached.lastAttempt || cached.timestamp || 0;
84
+ if (now - lastAttempt < API_RETRY_TTL_MS)
81
85
  return staleData;
82
86
  }
83
87
  catch { }
84
88
  }
89
+ try {
90
+ writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: staleData ? (lastAttempt || now) : 0, lastAttempt: now }), "utf-8");
91
+ }
92
+ catch { }
85
93
  try {
86
94
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
87
95
  const code = config.groups?.[0];
@@ -99,14 +107,10 @@ function getCcclubRank() {
99
107
  if (!me)
100
108
  return staleData;
101
109
  const result = { rank: me.rank, total: rankings.length, cost: me.costUSD };
102
- writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: Date.now() }), "utf-8");
110
+ writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now, lastAttempt: now }), "utf-8");
103
111
  return result;
104
112
  }
105
113
  catch {
106
- try {
107
- writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: Date.now() }), "utf-8");
108
- }
109
- catch { }
110
114
  return staleData;
111
115
  }
112
116
  }
@@ -119,44 +123,65 @@ export function rankColor(rank) {
119
123
  return FG_ORANGE;
120
124
  return FG_CYAN;
121
125
  }
122
- // Claude usage fetcher — TTL-based cache (stale fallback on failure)
126
+ // Claude usage fetcher — token-aware cache: detects token rotation to retry immediately
123
127
  function getClaudeUsage() {
124
128
  const cacheFile = "/tmp/sl-claude-usage";
125
129
  const hitFile = "/tmp/sl-claude-usage-hit";
126
130
  const now = Date.now();
131
+ // Cache format: { data, lastAttempt, tokenPrefix (first 20 chars of token) }
127
132
  let staleData = null;
133
+ let lastAttempt = 0;
134
+ let cachedTokenPrefix = "";
128
135
  if (existsSync(cacheFile)) {
129
136
  try {
130
137
  const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
131
138
  staleData = cached.data ?? null;
132
- const cacheAge = now - (cached.timestamp || 0);
133
- if (cacheAge < CACHE_TTL_MS)
134
- return staleData;
139
+ lastAttempt = cached.lastAttempt || 0;
140
+ cachedTokenPrefix = cached.tokenPrefix || "";
135
141
  }
136
142
  catch { }
137
143
  }
144
+ // Get current token
145
+ let accessToken = "";
138
146
  try {
139
147
  const username = process.env.USER || process.env.USERNAME;
140
148
  const keychainCmd = `security find-generic-password -s "Claude Code-credentials" -a "${username}" -w 2>/dev/null`;
141
149
  const credentialsJSON = execSync(keychainCmd, { encoding: "utf-8", timeout: 2000 }).trim();
142
150
  if (!credentialsJSON)
143
- return null;
151
+ return staleData;
144
152
  const credentials = JSON.parse(credentialsJSON);
145
- const accessToken = credentials.claudeAiOauth?.accessToken;
153
+ accessToken = credentials.claudeAiOauth?.accessToken || "";
146
154
  if (!accessToken)
147
- return null;
155
+ return staleData;
148
156
  const expiresAt = credentials.claudeAiOauth?.expiresAt;
149
- if (expiresAt && Date.now() / 1000 > expiresAt)
150
- return null;
157
+ if (expiresAt && now > expiresAt)
158
+ return staleData;
159
+ }
160
+ catch {
161
+ return staleData;
162
+ }
163
+ const currentTokenPrefix = accessToken.slice(-20);
164
+ const tokenChanged = cachedTokenPrefix && currentTokenPrefix !== cachedTokenPrefix;
165
+ // Throttle: skip API call unless token rotated or TTL expired
166
+ if (!tokenChanged && lastAttempt && now - lastAttempt < API_RETRY_TTL_MS)
167
+ return staleData;
168
+ // Mark attempt and store token prefix
169
+ try {
170
+ writeFileSync(cacheFile, JSON.stringify({ data: staleData, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
171
+ }
172
+ catch { }
173
+ try {
151
174
  const apiUrl = "https://api.anthropic.com/api/oauth/usage";
152
175
  const curlCmd = `curl -sf "${apiUrl}" -H "Authorization: Bearer ${accessToken}" -H "anthropic-beta: oauth-2025-04-20"`;
153
176
  const response = execSync(curlCmd, { encoding: "utf-8", timeout: 5000 });
154
- if (!response) {
155
- // API failed — write cache with null data to prevent retry flood
156
- writeFileSync(cacheFile, JSON.stringify({ data: null, timestamp: now }), "utf-8");
177
+ if (!response)
157
178
  return staleData;
158
- }
159
179
  const data = JSON.parse(response);
180
+ // Debug: save raw API response for diagnosis
181
+ try {
182
+ writeFileSync("/tmp/sl-claude-usage-raw", JSON.stringify(data, null, 2), "utf-8");
183
+ }
184
+ catch { }
160
185
  const parseUtil = (val) => {
161
186
  if (typeof val === "number")
162
187
  return Math.round(val);
@@ -200,15 +225,10 @@ function getClaudeUsage() {
200
225
  const result = { fiveHour, sevenDay };
201
226
  if (fiveHourResetsAt)
202
227
  result.fiveHourResetsAt = fiveHourResetsAt;
203
- writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now }), "utf-8");
228
+ writeFileSync(cacheFile, JSON.stringify({ data: result, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
204
229
  return result;
205
230
  }
206
231
  catch {
207
- // Write cache with stale/null data to prevent retry flood on persistent failures
208
- try {
209
- writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: now }), "utf-8");
210
- }
211
- catch { }
212
232
  return staleData;
213
233
  }
214
234
  }
@@ -222,7 +242,7 @@ export function render(input) {
222
242
  }
223
243
  // Session data from Claude Code stdin
224
244
  const cost = data.cost?.total_cost_usd ?? 0;
225
- const model = data.model?.display_name ?? "—";
245
+ const model = (data.model?.display_name ?? "—").replace(/\s*\((\d+[KMB])\s+context\)/i, " ($1)");
226
246
  const contextPct = Math.floor(data.context_window?.used_percentage ?? 0);
227
247
  const transcriptPath = data.transcript_path ?? "";
228
248
  // Token stats from transcript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-costline",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Enhanced statusline for Claude Code with cost tracking, usage limits, and leaderboard",
5
5
  "type": "module",
6
6
  "bin": {