cc-costline 0.4.0 → 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.
@@ -1,6 +1,8 @@
1
+ import type { CacheData } from "./cache.js";
1
2
  export declare function formatTokens(t: number): string;
2
3
  export declare function formatCost(n: number): string;
3
4
  export declare function ctxColor(pct: number): string;
4
5
  export declare function formatCountdown(resetsAtMs: number): string;
6
+ export declare function shouldRefreshLocalCostCache(cache: CacheData | null, transcriptPath?: string, now?: number): boolean;
5
7
  export declare function rankColor(rank: number): string;
6
8
  export declare function render(input: string): string;
@@ -1,11 +1,13 @@
1
- import { readFileSync, existsSync, writeFileSync, unlinkSync } from "node:fs";
1
+ import { readFileSync, existsSync, statSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  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";
@@ -49,23 +51,45 @@ export function formatCountdown(resetsAtMs) {
49
51
  const minutes = totalMinutes % 60;
50
52
  return `-${hours}:${String(minutes).padStart(2, "0")}`;
51
53
  }
52
- // ccclub rank fetcher TTL-based cache (stale fallback on failure)
54
+ export function shouldRefreshLocalCostCache(cache, transcriptPath = "", now = Date.now()) {
55
+ if (!cache)
56
+ return true;
57
+ const cacheUpdatedAt = new Date(cache.updatedAt).getTime();
58
+ if (isNaN(cacheUpdatedAt))
59
+ return true;
60
+ if (transcriptPath) {
61
+ try {
62
+ const transcriptMtime = statSync(transcriptPath).mtimeMs;
63
+ if (transcriptMtime > cacheUpdatedAt)
64
+ return true;
65
+ }
66
+ catch { }
67
+ }
68
+ return now - cacheUpdatedAt >= CACHE_TTL_MS;
69
+ }
70
+ // ccclub rank fetcher — split cache: data persists, retry throttled
53
71
  function getCcclubRank() {
54
72
  const configPath = join(homedir(), ".ccclub", "config.json");
55
73
  if (!existsSync(configPath))
56
74
  return null;
57
75
  const cacheFile = "/tmp/sl-ccclub-rank";
76
+ const now = Date.now();
58
77
  let staleData = null;
78
+ let lastAttempt = 0;
59
79
  if (existsSync(cacheFile)) {
60
80
  try {
61
81
  const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
62
82
  staleData = cached.data ?? null;
63
- const cacheAge = Date.now() - (cached.timestamp || 0);
64
- if (cacheAge < CACHE_TTL_MS)
83
+ lastAttempt = cached.lastAttempt || cached.timestamp || 0;
84
+ if (now - lastAttempt < API_RETRY_TTL_MS)
65
85
  return staleData;
66
86
  }
67
87
  catch { }
68
88
  }
89
+ try {
90
+ writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: staleData ? (lastAttempt || now) : 0, lastAttempt: now }), "utf-8");
91
+ }
92
+ catch { }
69
93
  try {
70
94
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
71
95
  const code = config.groups?.[0];
@@ -83,14 +107,10 @@ function getCcclubRank() {
83
107
  if (!me)
84
108
  return staleData;
85
109
  const result = { rank: me.rank, total: rankings.length, cost: me.costUSD };
86
- writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: Date.now() }), "utf-8");
110
+ writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now, lastAttempt: now }), "utf-8");
87
111
  return result;
88
112
  }
89
113
  catch {
90
- try {
91
- writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: Date.now() }), "utf-8");
92
- }
93
- catch { }
94
114
  return staleData;
95
115
  }
96
116
  }
@@ -103,44 +123,65 @@ export function rankColor(rank) {
103
123
  return FG_ORANGE;
104
124
  return FG_CYAN;
105
125
  }
106
- // Claude usage fetcher — TTL-based cache (stale fallback on failure)
126
+ // Claude usage fetcher — token-aware cache: detects token rotation to retry immediately
107
127
  function getClaudeUsage() {
108
128
  const cacheFile = "/tmp/sl-claude-usage";
109
129
  const hitFile = "/tmp/sl-claude-usage-hit";
110
130
  const now = Date.now();
131
+ // Cache format: { data, lastAttempt, tokenPrefix (first 20 chars of token) }
111
132
  let staleData = null;
133
+ let lastAttempt = 0;
134
+ let cachedTokenPrefix = "";
112
135
  if (existsSync(cacheFile)) {
113
136
  try {
114
137
  const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
115
138
  staleData = cached.data ?? null;
116
- const cacheAge = now - (cached.timestamp || 0);
117
- if (cacheAge < CACHE_TTL_MS)
118
- return staleData;
139
+ lastAttempt = cached.lastAttempt || 0;
140
+ cachedTokenPrefix = cached.tokenPrefix || "";
119
141
  }
120
142
  catch { }
121
143
  }
144
+ // Get current token
145
+ let accessToken = "";
122
146
  try {
123
147
  const username = process.env.USER || process.env.USERNAME;
124
148
  const keychainCmd = `security find-generic-password -s "Claude Code-credentials" -a "${username}" -w 2>/dev/null`;
125
149
  const credentialsJSON = execSync(keychainCmd, { encoding: "utf-8", timeout: 2000 }).trim();
126
150
  if (!credentialsJSON)
127
- return null;
151
+ return staleData;
128
152
  const credentials = JSON.parse(credentialsJSON);
129
- const accessToken = credentials.claudeAiOauth?.accessToken;
153
+ accessToken = credentials.claudeAiOauth?.accessToken || "";
130
154
  if (!accessToken)
131
- return null;
155
+ return staleData;
132
156
  const expiresAt = credentials.claudeAiOauth?.expiresAt;
133
- if (expiresAt && Date.now() / 1000 > expiresAt)
134
- 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 {
135
174
  const apiUrl = "https://api.anthropic.com/api/oauth/usage";
136
175
  const curlCmd = `curl -sf "${apiUrl}" -H "Authorization: Bearer ${accessToken}" -H "anthropic-beta: oauth-2025-04-20"`;
137
176
  const response = execSync(curlCmd, { encoding: "utf-8", timeout: 5000 });
138
- if (!response) {
139
- // API failed — write cache with null data to prevent retry flood
140
- writeFileSync(cacheFile, JSON.stringify({ data: null, timestamp: now }), "utf-8");
177
+ if (!response)
141
178
  return staleData;
142
- }
143
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 { }
144
185
  const parseUtil = (val) => {
145
186
  if (typeof val === "number")
146
187
  return Math.round(val);
@@ -184,15 +225,10 @@ function getClaudeUsage() {
184
225
  const result = { fiveHour, sevenDay };
185
226
  if (fiveHourResetsAt)
186
227
  result.fiveHourResetsAt = fiveHourResetsAt;
187
- writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now }), "utf-8");
228
+ writeFileSync(cacheFile, JSON.stringify({ data: result, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
188
229
  return result;
189
230
  }
190
231
  catch {
191
- // Write cache with stale/null data to prevent retry flood on persistent failures
192
- try {
193
- writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: now }), "utf-8");
194
- }
195
- catch { }
196
232
  return staleData;
197
233
  }
198
234
  }
@@ -206,7 +242,7 @@ export function render(input) {
206
242
  }
207
243
  // Session data from Claude Code stdin
208
244
  const cost = data.cost?.total_cost_usd ?? 0;
209
- const model = data.model?.display_name ?? "—";
245
+ const model = (data.model?.display_name ?? "—").replace(/\s*\((\d+[KMB])\s+context\)/i, " ($1)");
210
246
  const contextPct = Math.floor(data.context_window?.used_percentage ?? 0);
211
247
  const transcriptPath = data.transcript_path ?? "";
212
248
  // Token stats from transcript
@@ -231,8 +267,7 @@ export function render(input) {
231
267
  }
232
268
  // Refresh local cost cache if stale
233
269
  let cache = readCache();
234
- const cacheAge = cache ? Date.now() - new Date(cache.updatedAt).getTime() : Infinity;
235
- if (cacheAge >= CACHE_TTL_MS) {
270
+ if (shouldRefreshLocalCostCache(cache, transcriptPath)) {
236
271
  try {
237
272
  const result = collectCosts();
238
273
  // Don't overwrite valid cache with zeros (directory read failure)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-costline",
3
- "version": "0.4.0",
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": {