cc-costline 0.2.4 → 0.3.1

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.
package/README.es.md CHANGED
@@ -23,7 +23,7 @@ Abre una nueva sesión de Claude Code y verás la statusline mejorada. Requiere
23
23
  | Segmento | Ejemplo | Descripción |
24
24
  |----------|---------|-------------|
25
25
  | Tokens ~ Costo / Contexto | `14.6k ~ $2.42 / 40% by Opus 4.6` | Tokens de la sesión, costo, uso de contexto y modelo |
26
- | Límites de uso | `5h: 45% / 7d: 8%` | Utilización de Claude a 5 horas y 7 días (coloreado como el contexto) |
26
+ | Límites de uso | `5h: 45% / 7d: 8%` | Utilización de Claude a 5 horas y 7 días (coloreado como el contexto). Al 100%, muestra cuenta regresiva: `5h:-3:20` |
27
27
  | Costo del período | `30d: $866` | Costo acumulado (configurable: 7d o 30d) |
28
28
  | Ranking | `#2/22 $67.0` | Posición en [ccclub](https://github.com/mazzzystar/ccclub) (si está instalado) |
29
29
 
@@ -77,6 +77,12 @@ Los modelos desconocidos usan el precio de su familia, Sonnet por defecto.
77
77
 
78
78
  </details>
79
79
 
80
+ ## Desarrollo
81
+
82
+ ```bash
83
+ npm test # Build + ejecutar tests unitarios (node:test, sin dependencias)
84
+ ```
85
+
80
86
  ## Desinstalación
81
87
 
82
88
  ```bash
package/README.fr.md CHANGED
@@ -23,7 +23,7 @@ Ouvrez une nouvelle session Claude Code et la statusline enrichie apparaîtra. N
23
23
  | Segment | Exemple | Description |
24
24
  |---------|---------|-------------|
25
25
  | Tokens ~ Coût / Contexte | `14.6k ~ $2.42 / 40% by Opus 4.6` | Nombre de tokens, coût, utilisation du contexte et modèle |
26
- | Limites d'utilisation | `5h: 45% / 7d: 8%` | Utilisation Claude sur 5 heures et 7 jours (colorée comme le contexte) |
26
+ | Limites d'utilisation | `5h: 45% / 7d: 8%` | Utilisation Claude sur 5 heures et 7 jours (colorée comme le contexte). À 100 %, affiche un compte à rebours : `5h:-3:20` |
27
27
  | Coût périodique | `30d: $866` | Coût cumulé glissant (configurable : 7j ou 30j) |
28
28
  | Classement | `#2/22 $67.0` | Rang [ccclub](https://github.com/mazzzystar/ccclub) (si installé) |
29
29
 
@@ -77,6 +77,12 @@ Les modèles inconnus utilisent le prix de leur famille, Sonnet par défaut.
77
77
 
78
78
  </details>
79
79
 
80
+ ## Développement
81
+
82
+ ```bash
83
+ npm test # Build + exécuter les tests unitaires (node:test, zéro dépendance)
84
+ ```
85
+
80
86
  ## Désinstallation
81
87
 
82
88
  ```bash
package/README.ja.md CHANGED
@@ -23,7 +23,7 @@ npm i -g cc-costline && cc-costline install
23
23
  | セグメント | 例 | 説明 |
24
24
  |-----------|---|------|
25
25
  | トークン ~ コスト / コンテキスト | `14.6k ~ $2.42 / 40% by Opus 4.6` | セッションのトークン数、コスト、コンテキスト使用率、モデル |
26
- | 使用制限 | `5h: 45% / 7d: 8%` | Claude の 5 時間・7 日間の使用率(コンテキストと同じ色分け) |
26
+ | 使用制限 | `5h: 45% / 7d: 8%` | Claude の 5 時間・7 日間の使用率(コンテキストと同じ色分け)。100% 到達時はカウントダウン表示:`5h:-3:20` |
27
27
  | 期間コスト | `30d: $866` | ローリングコスト合計(7d または 30d で設定可能) |
28
28
  | リーダーボード | `#2/22 $67.0` | [ccclub](https://github.com/mazzzystar/ccclub) ランキング(インストール時) |
29
29
 
@@ -77,6 +77,12 @@ cc-costline config --period 7d # 7 日間のコストを表示
77
77
 
78
78
  </details>
79
79
 
80
+ ## 開発
81
+
82
+ ```bash
83
+ npm test # ビルド + ユニットテスト実行(node:test、依存関係なし)
84
+ ```
85
+
80
86
  ## アンインストール
81
87
 
82
88
  ```bash
package/README.md CHANGED
@@ -23,7 +23,7 @@ Open a new Claude Code session and you'll see the enhanced statusline. Requires
23
23
  | Segment | Example | Description |
24
24
  |---------|---------|-------------|
25
25
  | Tokens ~ Cost / Context | `14.6k ~ $2.42 / 40% by Opus 4.6` | Session token count, cost, context usage, and model |
26
- | Usage limits | `5h: 45% / 7d: 8%` | Claude 5-hour and 7-day utilization (auto-colored like context) |
26
+ | Usage limits | `5h: 45% / 7d: 8%` | Claude 5-hour and 7-day utilization (auto-colored like context). At 100%, shows countdown: `5h:-3:20` |
27
27
  | Period cost | `30d: $866` | Rolling cost total (configurable: 7d or 30d) |
28
28
  | Leaderboard | `#2/22 $67.0` | [ccclub](https://github.com/mazzzystar/ccclub) rank (if installed) |
29
29
 
@@ -77,6 +77,12 @@ Unknown models fall back by family name, defaulting to Sonnet pricing.
77
77
 
78
78
  </details>
79
79
 
80
+ ## Development
81
+
82
+ ```bash
83
+ npm test # Build + run unit tests (node:test, zero dependencies)
84
+ ```
85
+
80
86
  ## Uninstall
81
87
 
82
88
  ```bash
package/README.zh-CN.md CHANGED
@@ -23,7 +23,7 @@ npm i -g cc-costline && cc-costline install
23
23
  | 模块 | 示例 | 说明 |
24
24
  |------|------|------|
25
25
  | Token ~ 费用 / 上下文 | `14.6k ~ $2.42 / 40% by Opus 4.6` | 会话 token 数量、费用、上下文使用率和模型 |
26
- | 使用限额 | `5h: 45% / 7d: 8%` | Claude 5 小时和 7 天使用率(颜色同上下文) |
26
+ | 使用限额 | `5h: 45% / 7d: 8%` | Claude 5 小时和 7 天使用率(颜色同上下文)。达到 100% 时显示倒计时:`5h:-3:20` |
27
27
  | 周期费用 | `30d: $866` | 滚动费用合计(可配置:7d 或 30d) |
28
28
  | 排行榜 | `#2/22 $67.0` | [ccclub](https://github.com/mazzzystar/ccclub) 排名(需安装) |
29
29
 
@@ -77,6 +77,12 @@ cc-costline config --period 7d # 显示 7 天费用
77
77
 
78
78
  </details>
79
79
 
80
+ ## 开发
81
+
82
+ ```bash
83
+ npm test # 构建 + 运行单元测试(node:test,零依赖)
84
+ ```
85
+
80
86
  ## 卸载
81
87
 
82
88
  ```bash
@@ -1 +1,6 @@
1
+ export declare function formatTokens(t: number): string;
2
+ export declare function formatCost(n: number): string;
3
+ export declare function ctxColor(pct: number): string;
4
+ export declare function formatCountdown(resetsAtMs: number): string;
5
+ export declare function rankColor(rank: number): string;
1
6
  export declare function render(input: string): string;
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, statSync, writeFileSync } from "node:fs";
1
+ import { readFileSync, existsSync, 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";
@@ -14,14 +14,14 @@ const FG_MODEL = "\x1b[38;2;202;124;94m";
14
14
  const FG_CYAN = "\x1b[38;5;109m";
15
15
  const FG_WHITE = "\x1b[38;5;255m";
16
16
  const RESET = "\x1b[0m";
17
- function formatTokens(t) {
17
+ export function formatTokens(t) {
18
18
  if (t >= 1_000_000)
19
19
  return (t / 1_000_000).toFixed(1) + "M";
20
20
  if (t >= 1_000)
21
21
  return (t / 1_000).toFixed(1) + "k";
22
22
  return String(t);
23
23
  }
24
- function formatCost(n) {
24
+ export function formatCost(n) {
25
25
  if (n >= 1000)
26
26
  return "$" + Math.round(n).toLocaleString("en-US");
27
27
  if (n >= 100)
@@ -30,54 +30,63 @@ function formatCost(n) {
30
30
  return "$" + n.toFixed(1);
31
31
  return "$" + n.toFixed(2);
32
32
  }
33
- function ctxColor(pct) {
33
+ export function ctxColor(pct) {
34
34
  if (pct >= 80)
35
35
  return FG_RED;
36
36
  if (pct >= 60)
37
37
  return FG_ORANGE;
38
38
  return FG_GREEN;
39
39
  }
40
- // ccclub rank fetcher with 120s file cache
41
- function getCcclubRank() {
40
+ export function formatCountdown(resetsAtMs) {
41
+ const remainingMs = resetsAtMs - Date.now();
42
+ if (remainingMs <= 0)
43
+ return "~0:00";
44
+ const totalMinutes = Math.ceil(remainingMs / 60000);
45
+ const hours = Math.floor(totalMinutes / 60);
46
+ const minutes = totalMinutes % 60;
47
+ return `-${hours}:${String(minutes).padStart(2, "0")}`;
48
+ }
49
+ // ccclub rank fetcher — cached per session (stale fallback on failure)
50
+ function getCcclubRank(sessionId) {
42
51
  const configPath = join(homedir(), ".ccclub", "config.json");
43
52
  if (!existsSync(configPath))
44
53
  return null;
45
54
  const cacheFile = "/tmp/sl-ccclub-rank";
46
- const now = Date.now() / 1000;
55
+ let staleData = null;
47
56
  if (existsSync(cacheFile)) {
48
- const mtime = statSync(cacheFile).mtimeMs / 1000;
49
- if (now - mtime <= 120) {
50
- try {
51
- return JSON.parse(readFileSync(cacheFile, "utf-8"));
52
- }
53
- catch { }
57
+ try {
58
+ const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
59
+ staleData = cached.data ?? null;
60
+ if (cached.sessionId === sessionId)
61
+ return staleData;
54
62
  }
63
+ catch { }
55
64
  }
56
65
  try {
57
66
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
58
67
  const code = config.groups?.[0];
59
68
  const userId = config.userId;
60
69
  if (!code || !userId)
61
- return null;
70
+ return staleData;
62
71
  const tz = -(new Date()).getTimezoneOffset();
63
72
  const url = `${config.apiUrl}/api/rank/${code}?period=today&tz=${tz}`;
64
73
  const response = execSync(`curl -sf "${url}"`, { encoding: "utf-8", timeout: 5000 });
65
74
  if (!response)
66
- return null;
75
+ return staleData;
67
76
  const data = JSON.parse(response);
68
77
  const rankings = data.rankings || [];
69
78
  const me = rankings.find((r) => r.userId === userId);
70
79
  if (!me)
71
- return null;
80
+ return staleData;
72
81
  const result = { rank: me.rank, total: rankings.length, cost: me.costUSD };
73
- writeFileSync(cacheFile, JSON.stringify(result), "utf-8");
82
+ writeFileSync(cacheFile, JSON.stringify({ sessionId, data: result }), "utf-8");
74
83
  return result;
75
84
  }
76
85
  catch {
77
- return null;
86
+ return staleData;
78
87
  }
79
88
  }
80
- function rankColor(rank) {
89
+ export function rankColor(rank) {
81
90
  if (rank === 1)
82
91
  return FG_YELLOW;
83
92
  if (rank === 2)
@@ -86,18 +95,20 @@ function rankColor(rank) {
86
95
  return FG_ORANGE;
87
96
  return FG_CYAN;
88
97
  }
89
- // Claude usage fetcher with 60s file cache
90
- function getClaudeUsage() {
98
+ // Claude usage fetcher cached per session (stale fallback on failure)
99
+ function getClaudeUsage(sessionId) {
91
100
  const cacheFile = "/tmp/sl-claude-usage";
92
- const now = Date.now() / 1000;
101
+ const hitFile = "/tmp/sl-claude-usage-hit";
102
+ const now = Date.now();
103
+ let staleData = null;
93
104
  if (existsSync(cacheFile)) {
94
- const mtime = statSync(cacheFile).mtimeMs / 1000;
95
- if (now - mtime <= 60) {
96
- try {
97
- return JSON.parse(readFileSync(cacheFile, "utf-8"));
98
- }
99
- catch { }
105
+ try {
106
+ const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
107
+ staleData = cached.data ?? null;
108
+ if (cached.sessionId === sessionId)
109
+ return staleData;
100
110
  }
111
+ catch { }
101
112
  }
102
113
  try {
103
114
  const username = process.env.USER || process.env.USERNAME;
@@ -125,12 +136,47 @@ function getClaudeUsage() {
125
136
  return Math.round(parseFloat(val.replace("%", "")));
126
137
  return 0;
127
138
  };
128
- const result = { fiveHour: parseUtil(data.five_hour?.utilization), sevenDay: parseUtil(data.seven_day?.utilization) };
129
- writeFileSync(cacheFile, JSON.stringify(result), "utf-8");
139
+ const fiveHour = parseUtil(data.five_hour?.utilization);
140
+ const sevenDay = parseUtil(data.seven_day?.utilization);
141
+ let fiveHourResetsAt;
142
+ // Strategy 1: Use reset time from API if available
143
+ const resetsAtRaw = data.five_hour?.resets_at ?? data.five_hour?.reset_at ?? data.five_hour?.next_reset;
144
+ if (resetsAtRaw) {
145
+ const ts = typeof resetsAtRaw === "string" ? new Date(resetsAtRaw).getTime() : resetsAtRaw * 1000;
146
+ if (!isNaN(ts) && ts > now)
147
+ fiveHourResetsAt = ts;
148
+ }
149
+ // Strategy 2: Fallback - track when we first saw 100%
150
+ if (fiveHour >= 100) {
151
+ if (!fiveHourResetsAt) {
152
+ if (existsSync(hitFile)) {
153
+ const hitTime = parseFloat(readFileSync(hitFile, "utf-8").trim());
154
+ if (!isNaN(hitTime)) {
155
+ fiveHourResetsAt = hitTime + 5 * 3600 * 1000;
156
+ }
157
+ }
158
+ else {
159
+ writeFileSync(hitFile, String(now), "utf-8");
160
+ fiveHourResetsAt = now + 5 * 3600 * 1000;
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ // Usage dropped below 100%, clear hit tracker
166
+ try {
167
+ if (existsSync(hitFile))
168
+ unlinkSync(hitFile);
169
+ }
170
+ catch { }
171
+ }
172
+ const result = { fiveHour, sevenDay };
173
+ if (fiveHourResetsAt)
174
+ result.fiveHourResetsAt = fiveHourResetsAt;
175
+ writeFileSync(cacheFile, JSON.stringify({ sessionId, data: result }), "utf-8");
130
176
  return result;
131
177
  }
132
178
  catch {
133
- return null;
179
+ return staleData;
134
180
  }
135
181
  }
136
182
  export function render(input) {
@@ -145,9 +191,11 @@ export function render(input) {
145
191
  const cost = data.cost?.total_cost_usd ?? 0;
146
192
  const model = data.model?.display_name ?? "—";
147
193
  const contextPct = Math.floor(data.context_window?.used_percentage ?? 0);
194
+ // Session ID from transcript path (filename without extension)
195
+ const transcriptPath = data.transcript_path ?? "";
196
+ const sessionId = transcriptPath ? transcriptPath.replace(/^.*\//, "").replace(/\.jsonl$/, "") : "";
148
197
  // Token stats from transcript
149
198
  let totalTokens = 0;
150
- const transcriptPath = data.transcript_path ?? "";
151
199
  if (transcriptPath) {
152
200
  try {
153
201
  const content = readFileSync(transcriptPath, "utf-8");
@@ -168,7 +216,7 @@ export function render(input) {
168
216
  }
169
217
  const cache = readCache();
170
218
  const config = readConfig();
171
- const claudeUsage = getClaudeUsage();
219
+ const claudeUsage = getClaudeUsage(sessionId);
172
220
  const g = FG_GRAY_DIM;
173
221
  const y = FG_YELLOW;
174
222
  const m = FG_MODEL;
@@ -181,9 +229,15 @@ export function render(input) {
181
229
  // 5h:100% · 7d:26% · 30d:$960
182
230
  const usageParts = [];
183
231
  if (claudeUsage) {
184
- const c5 = ctxColor(claudeUsage.fiveHour);
232
+ if (claudeUsage.fiveHour >= 100 && claudeUsage.fiveHourResetsAt) {
233
+ const countdown = formatCountdown(claudeUsage.fiveHourResetsAt);
234
+ usageParts.push(`${FG_RED}5h:${countdown}${r}`);
235
+ }
236
+ else {
237
+ const c5 = ctxColor(claudeUsage.fiveHour);
238
+ usageParts.push(`${c5}5h:${claudeUsage.fiveHour}%${r}`);
239
+ }
185
240
  const c7 = ctxColor(claudeUsage.sevenDay);
186
- usageParts.push(`${c5}5h:${claudeUsage.fiveHour}%${r}`);
187
241
  usageParts.push(`${c7}7d:${claudeUsage.sevenDay}%${r}`);
188
242
  }
189
243
  if (cache) {
@@ -195,7 +249,7 @@ export function render(input) {
195
249
  segments.push(usageParts.join(` ${g}·${r} `));
196
250
  }
197
251
  // #2 $53.6
198
- const ccclubRank = getCcclubRank();
252
+ const ccclubRank = getCcclubRank(sessionId);
199
253
  if (ccclubRank) {
200
254
  const rc = rankColor(ccclubRank.rank);
201
255
  segments.push(`${rc}#${ccclubRank.rank} ${formatCost(ccclubRank.cost)}${r}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-costline",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Enhanced statusline for Claude Code with cost tracking, usage limits, and leaderboard",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc",
11
- "dev": "tsc --watch"
11
+ "dev": "tsc --watch",
12
+ "test": "tsc && node --experimental-strip-types --no-warnings --test test/*.test.ts"
12
13
  },
13
14
  "files": [
14
15
  "dist"