cc-costline 0.4.1 → 0.5.0
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 +4 -4
- package/README.fr.md +4 -4
- package/README.ja.md +4 -4
- package/README.md +4 -4
- package/README.zh-CN.md +4 -4
- package/dist/cache.d.ts +6 -0
- package/dist/cli.js +12 -2
- package/dist/collector.d.ts +7 -1
- package/dist/collector.js +83 -40
- package/dist/refresh.d.ts +1 -0
- package/dist/refresh.js +226 -0
- package/dist/statusline.js +51 -157
- package/package.json +1 -1
package/README.es.md
CHANGED
|
@@ -54,10 +54,10 @@ cc-costline config --period both # Mostrar ambos períodos
|
|
|
54
54
|
## Cómo funciona
|
|
55
55
|
|
|
56
56
|
1. `install` configura `~/.claude/settings.json` — establece el comando de statusline y añade hooks de fin de sesión. Tu configuración existente se conserva.
|
|
57
|
-
2. `render` es llamado por Claude Code en cada turno de conversación. Lee el JSON de stdin para datos de sesión, luego actualiza
|
|
58
|
-
- **Costo local
|
|
59
|
-
- **Límites de uso
|
|
60
|
-
- **Ranking ccclub
|
|
57
|
+
2. `render` es llamado por Claude Code en cada turno de conversación. Lee el JSON de stdin para datos de sesión, luego actualiza las fuentes de datos con TTLs separados:
|
|
58
|
+
- **Costo local** (TTL 2 min): escanea `~/.claude/projects/**/*.jsonl`, aplica precios por modelo → `~/.cc-costline/cache.json`
|
|
59
|
+
- **Límites de uso** (retry 5 min, sensible al token): obtiene de `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`. Detecta la rotación del token OAuth para reintentar inmediatamente (nuevo token = nueva cuota de límite). Los datos obsoletos persisten ante fallos.
|
|
60
|
+
- **Ranking ccclub** (retry 5 min): obtiene de `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
|
|
61
61
|
3. `refresh` también puede ejecutarse manualmente o mediante hooks de fin de sesión para precalentar la caché.
|
|
62
62
|
|
|
63
63
|
<details>
|
package/README.fr.md
CHANGED
|
@@ -54,10 +54,10 @@ cc-costline config --period both # Afficher les deux périodes
|
|
|
54
54
|
## Fonctionnement
|
|
55
55
|
|
|
56
56
|
1. `install` configure `~/.claude/settings.json` — définit la commande statusline et ajoute des hooks de fin de session. Vos paramètres existants sont préservés.
|
|
57
|
-
2. `render` est appelé par Claude Code à chaque tour de conversation. Il lit le JSON stdin pour les données de session, puis rafraîchit
|
|
58
|
-
- **Coût local** : parcourt `~/.claude/projects/**/*.jsonl`, applique la tarification par modèle → `~/.cc-costline/cache.json`
|
|
59
|
-
- **Limites d'utilisation** : récupère depuis `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage
|
|
60
|
-
- **Rang ccclub** : récupère depuis `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
|
|
57
|
+
2. `render` est appelé par Claude Code à chaque tour de conversation. Il lit le JSON stdin pour les données de session, puis rafraîchit les sources de données avec des TTL séparés :
|
|
58
|
+
- **Coût local** (TTL 2 min) : parcourt `~/.claude/projects/**/*.jsonl`, applique la tarification par modèle → `~/.cc-costline/cache.json`
|
|
59
|
+
- **Limites d'utilisation** (retry 5 min, sensible au token) : récupère depuis `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`. Détecte la rotation du token OAuth pour relancer immédiatement (nouveau token = nouveau quota de débit). Les données périmées persistent en cas d'échec.
|
|
60
|
+
- **Rang ccclub** (retry 5 min) : récupère depuis `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
|
|
61
61
|
3. `refresh` peut aussi être exécuté manuellement ou via les hooks de fin de session pour préchauffer le cache.
|
|
62
62
|
|
|
63
63
|
<details>
|
package/README.ja.md
CHANGED
|
@@ -54,10 +54,10 @@ cc-costline config --period both # 両方の期間を表示
|
|
|
54
54
|
## 仕組み
|
|
55
55
|
|
|
56
56
|
1. `install` は `~/.claude/settings.json` を設定 — ステータスラインコマンドとセッション終了フックを追加します。既存の設定は保持されます。
|
|
57
|
-
2. `render` は毎回の対話時に Claude Code から呼び出され、stdin JSON
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
- **ccclub
|
|
57
|
+
2. `render` は毎回の対話時に Claude Code から呼び出され、stdin JSON からセッションデータを読み取り、各データソースを個別の TTL でインライン更新します:
|
|
58
|
+
- **ローカルコスト**(2 分 TTL):`~/.claude/projects/**/*.jsonl` をスキャン、モデル別価格を適用 → `~/.cc-costline/cache.json`
|
|
59
|
+
- **使用率**(5 分リトライ、トークンローテーション検知):`api.anthropic.com/api/oauth/usage` から取得 → `/tmp/sl-claude-usage`。OAuth トークンのローテーションを検知し、即座にリトライ(新トークン=新レート制限枠)。API 失敗時も過去のデータを保持。
|
|
60
|
+
- **ccclub ランキング**(5 分リトライ):`ccclub.dev/api/rank` から取得 → `/tmp/sl-ccclub-rank`
|
|
61
61
|
3. `refresh` は手動実行やセッション終了フックによるキャッシュウォームアップにも使用できます。
|
|
62
62
|
|
|
63
63
|
<details>
|
package/README.md
CHANGED
|
@@ -54,10 +54,10 @@ cc-costline config --period both # Show both periods
|
|
|
54
54
|
## How it works
|
|
55
55
|
|
|
56
56
|
1. `install` configures `~/.claude/settings.json` — sets the statusline command and adds session-end hooks. Your existing settings are preserved.
|
|
57
|
-
2. `render` is called by Claude Code on every turn. It reads stdin JSON for session data, then refreshes
|
|
58
|
-
- **Local cost
|
|
59
|
-
- **Usage limits
|
|
60
|
-
- **ccclub rank
|
|
57
|
+
2. `render` is called by Claude Code on every turn. It reads stdin JSON for session data, then refreshes data sources inline with separate TTLs:
|
|
58
|
+
- **Local cost** (2-min TTL): scans `~/.claude/projects/**/*.jsonl`, applies per-model pricing → `~/.cc-costline/cache.json`
|
|
59
|
+
- **Usage limits** (5-min retry, token-aware): fetches `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`. Detects OAuth token rotation to retry immediately with fresh rate limit quota. Stale data persists across failures.
|
|
60
|
+
- **ccclub rank** (5-min retry): fetches `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
|
|
61
61
|
3. `refresh` can also be run manually or via session-end hooks to warm the cost cache.
|
|
62
62
|
|
|
63
63
|
<details>
|
package/README.zh-CN.md
CHANGED
|
@@ -54,10 +54,10 @@ cc-costline config --period both # 同时显示两个周期
|
|
|
54
54
|
## 工作原理
|
|
55
55
|
|
|
56
56
|
1. `install` 配置 `~/.claude/settings.json` — 设置状态栏命令并添加会话结束 hook。你的现有设置会被保留。
|
|
57
|
-
2. `render` 在每次对话时被 Claude Code 调用,读取 stdin JSON
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
- **ccclub
|
|
57
|
+
2. `render` 在每次对话时被 Claude Code 调用,读取 stdin JSON 获取会话数据,然后按独立 TTL 刷新各数据源:
|
|
58
|
+
- **本地费用**(2 分钟 TTL):扫描 `~/.claude/projects/**/*.jsonl`,按模型定价计算 → `~/.cc-costline/cache.json`
|
|
59
|
+
- **使用率**(5 分钟重试,感知 token 轮换):从 `api.anthropic.com/api/oauth/usage` 获取 → `/tmp/sl-claude-usage`。检测 OAuth token 轮换后立即重试(新 token 有新的速率配额)。API 失败时保留历史数据。
|
|
60
|
+
- **ccclub 排名**(5 分钟重试):从 `ccclub.dev/api/rank` 获取 → `/tmp/sl-ccclub-rank`
|
|
61
61
|
3. `refresh` 也可以手动运行或通过会话结束 hook 预热缓存。
|
|
62
62
|
|
|
63
63
|
<details>
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
declare const CACHE_DIR: string;
|
|
2
|
+
export interface FileCostEntry {
|
|
3
|
+
mtimeMs: number;
|
|
4
|
+
size: number;
|
|
5
|
+
byDay: Record<string, number>;
|
|
6
|
+
}
|
|
2
7
|
export interface CacheData {
|
|
3
8
|
cost7d: number;
|
|
4
9
|
cost30d: number;
|
|
5
10
|
updatedAt: string;
|
|
11
|
+
files?: Record<string, FileCostEntry>;
|
|
6
12
|
}
|
|
7
13
|
export interface ConfigData {
|
|
8
14
|
period: "7d" | "30d" | "both";
|
package/dist/cli.js
CHANGED
|
@@ -6,8 +6,9 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
8
8
|
import { collectCosts } from "./collector.js";
|
|
9
|
-
import { writeCache, writeConfig, readConfig, CACHE_DIR } from "./cache.js";
|
|
9
|
+
import { readCache, writeCache, writeConfig, readConfig, CACHE_DIR } from "./cache.js";
|
|
10
10
|
import { render } from "./statusline.js";
|
|
11
|
+
import { refreshAll } from "./refresh.js";
|
|
11
12
|
const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
12
13
|
const RENDER_COMMAND = "cc-costline render";
|
|
13
14
|
const REFRESH_COMMAND = "cc-costline refresh";
|
|
@@ -115,11 +116,13 @@ function cmdConfig(args) {
|
|
|
115
116
|
console.log(`✓ Period set to: ${period}`);
|
|
116
117
|
}
|
|
117
118
|
function cmdRefresh() {
|
|
118
|
-
const
|
|
119
|
+
const prev = readCache();
|
|
120
|
+
const result = collectCosts(undefined, prev?.files);
|
|
119
121
|
writeCache({
|
|
120
122
|
cost7d: result.cost7d,
|
|
121
123
|
cost30d: result.cost30d,
|
|
122
124
|
updatedAt: new Date().toISOString(),
|
|
125
|
+
files: result.files,
|
|
123
126
|
});
|
|
124
127
|
console.log(`✓ Cache updated — 7d: $${result.cost7d.toFixed(2)} | 30d: $${result.cost30d.toFixed(2)}`);
|
|
125
128
|
}
|
|
@@ -131,6 +134,10 @@ function cmdRender() {
|
|
|
131
134
|
if (output)
|
|
132
135
|
process.stdout.write(output);
|
|
133
136
|
}
|
|
137
|
+
function cmdRefreshBg(args) {
|
|
138
|
+
const transcriptPath = args[0] || "";
|
|
139
|
+
refreshAll(transcriptPath);
|
|
140
|
+
}
|
|
134
141
|
// ─── Main ─────────────────────────────────────────────────
|
|
135
142
|
const args = process.argv.slice(2);
|
|
136
143
|
const command = args[0];
|
|
@@ -147,6 +154,9 @@ switch (command) {
|
|
|
147
154
|
case "refresh":
|
|
148
155
|
cmdRefresh();
|
|
149
156
|
break;
|
|
157
|
+
case "refresh-bg":
|
|
158
|
+
cmdRefreshBg(args.slice(1));
|
|
159
|
+
break;
|
|
150
160
|
case "render":
|
|
151
161
|
cmdRender();
|
|
152
162
|
break;
|
package/dist/collector.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import type { FileCostEntry } from "./cache.js";
|
|
1
2
|
interface CollectResult {
|
|
2
3
|
cost7d: number;
|
|
3
4
|
cost30d: number;
|
|
5
|
+
files: Record<string, FileCostEntry>;
|
|
4
6
|
}
|
|
5
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Scan all jsonl files under projectsDir and compute cost7d/cost30d.
|
|
9
|
+
* Reuses prevFiles entries whose mtime+size haven't changed (incremental scan).
|
|
10
|
+
*/
|
|
11
|
+
export declare function collectCosts(baseDir?: string, prevFiles?: Record<string, FileCostEntry>): CollectResult;
|
|
6
12
|
export {};
|
package/dist/collector.js
CHANGED
|
@@ -31,63 +31,106 @@ function findJsonlFiles(dir) {
|
|
|
31
31
|
}
|
|
32
32
|
return results;
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
function dayKey(ms) {
|
|
35
|
+
const d = new Date(ms);
|
|
36
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
|
37
|
+
}
|
|
38
|
+
function dayStartMs(key) {
|
|
39
|
+
return new Date(key + "T00:00:00Z").getTime();
|
|
40
|
+
}
|
|
41
|
+
/** Parse a single jsonl file and bucket per-entry cost by UTC day. */
|
|
42
|
+
function parseFile(file, mtimeMs, size, cutoffMs) {
|
|
43
|
+
const byDay = {};
|
|
44
|
+
let content;
|
|
45
|
+
try {
|
|
46
|
+
content = readFileSync(file, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { mtimeMs, size, byDay };
|
|
50
|
+
}
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const lines = content.split("\n");
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (!line.trim())
|
|
55
|
+
continue;
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(line);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (parsed.type !== "assistant" || !parsed.message?.usage)
|
|
64
|
+
continue;
|
|
65
|
+
const ts = new Date(parsed.timestamp).getTime();
|
|
66
|
+
if (isNaN(ts) || ts < cutoffMs)
|
|
67
|
+
continue;
|
|
68
|
+
const usage = parsed.message.usage;
|
|
69
|
+
const requestId = parsed.requestId || "";
|
|
70
|
+
const sessionId = parsed.sessionId || "";
|
|
71
|
+
const dedupeKey = requestId
|
|
72
|
+
? `${sessionId}:${requestId}`
|
|
73
|
+
: `${sessionId}:${parsed.timestamp}:${parsed.message.model}:${usage.input_tokens}:${usage.output_tokens}:${usage.cache_creation_input_tokens || 0}:${usage.cache_read_input_tokens || 0}`;
|
|
74
|
+
if (seen.has(dedupeKey))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(dedupeKey);
|
|
77
|
+
const cost = calculateCost(parsed.message.model || "unknown", usage.input_tokens || 0, usage.output_tokens || 0, usage.cache_creation_input_tokens || 0, usage.cache_read_input_tokens || 0);
|
|
78
|
+
const day = dayKey(ts);
|
|
79
|
+
byDay[day] = (byDay[day] || 0) + cost;
|
|
80
|
+
}
|
|
81
|
+
return { mtimeMs, size, byDay };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Scan all jsonl files under projectsDir and compute cost7d/cost30d.
|
|
85
|
+
* Reuses prevFiles entries whose mtime+size haven't changed (incremental scan).
|
|
86
|
+
*/
|
|
87
|
+
export function collectCosts(baseDir, prevFiles) {
|
|
35
88
|
const projectsDir = baseDir || join(homedir(), CLAUDE_PROJECTS_DIR);
|
|
36
89
|
const files = findJsonlFiles(projectsDir);
|
|
37
90
|
if (files.length === 0) {
|
|
38
|
-
return { cost7d: 0, cost30d: 0 };
|
|
91
|
+
return { cost7d: 0, cost30d: 0, files: {} };
|
|
39
92
|
}
|
|
40
93
|
const now = Date.now();
|
|
41
94
|
const cutoff7d = now - 7 * 24 * 60 * 60 * 1000;
|
|
42
95
|
const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
|
|
96
|
+
const prev = prevFiles || {};
|
|
97
|
+
const out = {};
|
|
43
98
|
let cost7d = 0;
|
|
44
99
|
let cost30d = 0;
|
|
45
|
-
// Deduplication set (same as ccclub)
|
|
46
|
-
const seen = new Set();
|
|
47
100
|
for (const file of files) {
|
|
48
|
-
let
|
|
101
|
+
let stat;
|
|
49
102
|
try {
|
|
50
|
-
|
|
103
|
+
stat = statSync(file);
|
|
51
104
|
}
|
|
52
105
|
catch {
|
|
53
106
|
continue;
|
|
54
107
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
108
|
+
// Files not touched in the last 30d cannot contribute to either window
|
|
109
|
+
// (jsonl is append-only, so all entries inside are ≤ mtime).
|
|
110
|
+
if (stat.mtimeMs < cutoff30d)
|
|
111
|
+
continue;
|
|
112
|
+
let entry;
|
|
113
|
+
const cached = prev[file];
|
|
114
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
115
|
+
// Prune day buckets that have slid past the 30d window to keep cache from growing
|
|
116
|
+
const pruned = {};
|
|
117
|
+
for (const [day, cost] of Object.entries(cached.byDay)) {
|
|
118
|
+
if (dayStartMs(day) >= cutoff30d)
|
|
119
|
+
pruned[day] = cost;
|
|
65
120
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (seen.has(dedupeKey))
|
|
78
|
-
continue;
|
|
79
|
-
seen.add(dedupeKey);
|
|
80
|
-
const model = parsed.message.model || "unknown";
|
|
81
|
-
const inputTokens = usage.input_tokens || 0;
|
|
82
|
-
const outputTokens = usage.output_tokens || 0;
|
|
83
|
-
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
84
|
-
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
85
|
-
const cost = calculateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens);
|
|
86
|
-
cost30d += cost;
|
|
87
|
-
if (ts >= cutoff7d) {
|
|
121
|
+
entry = { mtimeMs: cached.mtimeMs, size: cached.size, byDay: pruned };
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
entry = parseFile(file, stat.mtimeMs, stat.size, cutoff30d);
|
|
125
|
+
}
|
|
126
|
+
out[file] = entry;
|
|
127
|
+
for (const [day, cost] of Object.entries(entry.byDay)) {
|
|
128
|
+
const ms = dayStartMs(day);
|
|
129
|
+
if (ms >= cutoff30d)
|
|
130
|
+
cost30d += cost;
|
|
131
|
+
if (ms >= cutoff7d)
|
|
88
132
|
cost7d += cost;
|
|
89
|
-
}
|
|
90
133
|
}
|
|
91
134
|
}
|
|
92
|
-
return { cost7d, cost30d };
|
|
135
|
+
return { cost7d, cost30d, files: out };
|
|
93
136
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function refreshAll(transcriptPath?: string): void;
|
package/dist/refresh.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, statSync, utimesSync, closeSync, openSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { readCache, writeCache } from "./cache.js";
|
|
6
|
+
import { collectCosts } from "./collector.js";
|
|
7
|
+
import { shouldRefreshLocalCostCache } from "./statusline.js";
|
|
8
|
+
// Anthropic usage API: strict per-token rate limits (~5 req/token). NEVER shrink.
|
|
9
|
+
const ANTHROPIC_TTL_MS = 300_000;
|
|
10
|
+
// ccclub rank: self-hosted, no strict limits — refresh more aggressively for visible data.
|
|
11
|
+
const CCCLUB_TTL_MS = 90_000;
|
|
12
|
+
const REFRESH_LOCK = "/tmp/sl-refresh.lock";
|
|
13
|
+
const REFRESH_LAST = "/tmp/sl-refresh.last";
|
|
14
|
+
const LOCK_STALE_MS = 60_000;
|
|
15
|
+
function acquireLock() {
|
|
16
|
+
try {
|
|
17
|
+
if (existsSync(REFRESH_LOCK)) {
|
|
18
|
+
try {
|
|
19
|
+
const stat = statSync(REFRESH_LOCK);
|
|
20
|
+
if (Date.now() - stat.mtimeMs < LOCK_STALE_MS)
|
|
21
|
+
return false;
|
|
22
|
+
unlinkSync(REFRESH_LOCK);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const fd = openSync(REFRESH_LOCK, "wx");
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(fd, String(process.pid));
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
closeSync(fd);
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function releaseLock() {
|
|
42
|
+
try {
|
|
43
|
+
unlinkSync(REFRESH_LOCK);
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
try {
|
|
47
|
+
const now = new Date();
|
|
48
|
+
if (!existsSync(REFRESH_LAST)) {
|
|
49
|
+
writeFileSync(REFRESH_LAST, "");
|
|
50
|
+
}
|
|
51
|
+
utimesSync(REFRESH_LAST, now, now);
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
55
|
+
function refreshLocalCost(transcriptPath) {
|
|
56
|
+
const cache = readCache();
|
|
57
|
+
if (!shouldRefreshLocalCostCache(cache, transcriptPath))
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
const result = collectCosts(undefined, cache?.files);
|
|
61
|
+
// Don't overwrite valid cache with zeros (directory read failure)
|
|
62
|
+
if (result.cost7d > 0 || result.cost30d > 0 || !cache) {
|
|
63
|
+
writeCache({
|
|
64
|
+
cost7d: result.cost7d,
|
|
65
|
+
cost30d: result.cost30d,
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
files: result.files,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
73
|
+
function refreshClaudeUsage() {
|
|
74
|
+
const cacheFile = "/tmp/sl-claude-usage";
|
|
75
|
+
const hitFile = "/tmp/sl-claude-usage-hit";
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
let staleData = null;
|
|
78
|
+
let lastAttempt = 0;
|
|
79
|
+
let cachedTokenPrefix = "";
|
|
80
|
+
if (existsSync(cacheFile)) {
|
|
81
|
+
try {
|
|
82
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
|
|
83
|
+
staleData = cached.data ?? null;
|
|
84
|
+
lastAttempt = cached.lastAttempt || 0;
|
|
85
|
+
cachedTokenPrefix = cached.tokenPrefix || "";
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}
|
|
89
|
+
let accessToken = "";
|
|
90
|
+
try {
|
|
91
|
+
const username = process.env.USER || process.env.USERNAME;
|
|
92
|
+
const keychainCmd = `security find-generic-password -s "Claude Code-credentials" -a "${username}" -w 2>/dev/null`;
|
|
93
|
+
const credentialsJSON = execSync(keychainCmd, { encoding: "utf-8", timeout: 2000 }).trim();
|
|
94
|
+
if (!credentialsJSON)
|
|
95
|
+
return;
|
|
96
|
+
const credentials = JSON.parse(credentialsJSON);
|
|
97
|
+
accessToken = credentials.claudeAiOauth?.accessToken || "";
|
|
98
|
+
if (!accessToken)
|
|
99
|
+
return;
|
|
100
|
+
const expiresAt = credentials.claudeAiOauth?.expiresAt;
|
|
101
|
+
if (expiresAt && now > expiresAt)
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const currentTokenPrefix = accessToken.slice(-20);
|
|
108
|
+
const tokenChanged = cachedTokenPrefix && currentTokenPrefix !== cachedTokenPrefix;
|
|
109
|
+
// Skip if cache is fresh and token hasn't rotated
|
|
110
|
+
if (!tokenChanged && lastAttempt && now - lastAttempt < ANTHROPIC_TTL_MS)
|
|
111
|
+
return;
|
|
112
|
+
// Mark attempt before HTTP — protects against repeated failures hammering the API
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(cacheFile, JSON.stringify({ data: staleData, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
|
|
115
|
+
}
|
|
116
|
+
catch { }
|
|
117
|
+
try {
|
|
118
|
+
const apiUrl = "https://api.anthropic.com/api/oauth/usage";
|
|
119
|
+
const curlCmd = `curl -sf "${apiUrl}" -H "Authorization: Bearer ${accessToken}" -H "anthropic-beta: oauth-2025-04-20"`;
|
|
120
|
+
const response = execSync(curlCmd, { encoding: "utf-8", timeout: 5000 });
|
|
121
|
+
if (!response)
|
|
122
|
+
return;
|
|
123
|
+
const data = JSON.parse(response);
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync("/tmp/sl-claude-usage-raw", JSON.stringify(data, null, 2), "utf-8");
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
const parseUtil = (val) => {
|
|
129
|
+
if (typeof val === "number")
|
|
130
|
+
return Math.round(val);
|
|
131
|
+
if (typeof val === "string")
|
|
132
|
+
return Math.round(parseFloat(val.replace("%", "")));
|
|
133
|
+
return 0;
|
|
134
|
+
};
|
|
135
|
+
const fiveHour = parseUtil(data.five_hour?.utilization);
|
|
136
|
+
const sevenDay = parseUtil(data.seven_day?.utilization);
|
|
137
|
+
let fiveHourResetsAt;
|
|
138
|
+
const resetsAtRaw = data.five_hour?.resets_at ?? data.five_hour?.reset_at ?? data.five_hour?.next_reset;
|
|
139
|
+
if (resetsAtRaw) {
|
|
140
|
+
const ts = typeof resetsAtRaw === "string" ? new Date(resetsAtRaw).getTime() : resetsAtRaw * 1000;
|
|
141
|
+
if (!isNaN(ts) && ts > now)
|
|
142
|
+
fiveHourResetsAt = ts;
|
|
143
|
+
}
|
|
144
|
+
if (fiveHour >= 100) {
|
|
145
|
+
if (!fiveHourResetsAt) {
|
|
146
|
+
if (existsSync(hitFile)) {
|
|
147
|
+
const hitTime = parseFloat(readFileSync(hitFile, "utf-8").trim());
|
|
148
|
+
if (!isNaN(hitTime))
|
|
149
|
+
fiveHourResetsAt = hitTime + 5 * 3600 * 1000;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
writeFileSync(hitFile, String(now), "utf-8");
|
|
153
|
+
fiveHourResetsAt = now + 5 * 3600 * 1000;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
try {
|
|
159
|
+
if (existsSync(hitFile))
|
|
160
|
+
unlinkSync(hitFile);
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
}
|
|
164
|
+
const result = { fiveHour, sevenDay };
|
|
165
|
+
if (fiveHourResetsAt)
|
|
166
|
+
result.fiveHourResetsAt = fiveHourResetsAt;
|
|
167
|
+
writeFileSync(cacheFile, JSON.stringify({ data: result, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
|
|
168
|
+
}
|
|
169
|
+
catch { }
|
|
170
|
+
}
|
|
171
|
+
function refreshCcclubRank() {
|
|
172
|
+
const configPath = join(homedir(), ".ccclub", "config.json");
|
|
173
|
+
if (!existsSync(configPath))
|
|
174
|
+
return;
|
|
175
|
+
const cacheFile = "/tmp/sl-ccclub-rank";
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
let staleData = null;
|
|
178
|
+
let lastAttempt = 0;
|
|
179
|
+
if (existsSync(cacheFile)) {
|
|
180
|
+
try {
|
|
181
|
+
const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
|
|
182
|
+
staleData = cached.data ?? null;
|
|
183
|
+
lastAttempt = cached.lastAttempt || cached.timestamp || 0;
|
|
184
|
+
if (now - lastAttempt < CCCLUB_TTL_MS)
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
catch { }
|
|
188
|
+
}
|
|
189
|
+
// Mark attempt before HTTP
|
|
190
|
+
try {
|
|
191
|
+
writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: staleData ? (lastAttempt || now) : 0, lastAttempt: now }), "utf-8");
|
|
192
|
+
}
|
|
193
|
+
catch { }
|
|
194
|
+
try {
|
|
195
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
196
|
+
const code = config.groups?.[0];
|
|
197
|
+
const userId = config.userId;
|
|
198
|
+
if (!code || !userId)
|
|
199
|
+
return;
|
|
200
|
+
const tz = -(new Date()).getTimezoneOffset();
|
|
201
|
+
const url = `${config.apiUrl}/api/rank/${code}?period=today&tz=${tz}`;
|
|
202
|
+
const response = execSync(`curl -sf "${url}"`, { encoding: "utf-8", timeout: 5000 });
|
|
203
|
+
if (!response)
|
|
204
|
+
return;
|
|
205
|
+
const data = JSON.parse(response);
|
|
206
|
+
const rankings = data.rankings || [];
|
|
207
|
+
const me = rankings.find((r) => r.userId === userId);
|
|
208
|
+
if (!me)
|
|
209
|
+
return;
|
|
210
|
+
const result = { rank: me.rank, total: rankings.length, cost: me.costUSD };
|
|
211
|
+
writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now, lastAttempt: now }), "utf-8");
|
|
212
|
+
}
|
|
213
|
+
catch { }
|
|
214
|
+
}
|
|
215
|
+
export function refreshAll(transcriptPath = "") {
|
|
216
|
+
if (!acquireLock())
|
|
217
|
+
return;
|
|
218
|
+
try {
|
|
219
|
+
refreshLocalCost(transcriptPath);
|
|
220
|
+
refreshClaudeUsage();
|
|
221
|
+
refreshCcclubRank();
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
releaseLock();
|
|
225
|
+
}
|
|
226
|
+
}
|
package/dist/statusline.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { readFileSync,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { readCache, writeCache, readConfig } from "./cache.js";
|
|
6
|
-
import { collectCosts } from "./collector.js";
|
|
7
|
-
// Unified TTL for all cached data (2 minutes)
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readCache, readConfig } from "./cache.js";
|
|
4
|
+
// TTL for local cost cache (2 minutes) — used by refresh-bg to decide whether to rescan jsonl
|
|
8
5
|
const CACHE_TTL_MS = 120_000;
|
|
6
|
+
// Throttle: render only spawns a background refresh if the last one finished > 30s ago.
|
|
7
|
+
// refresh-bg internally honors per-API TTLs (Anthropic 5min, ccclub 90s) so this is just
|
|
8
|
+
// a coarse "don't fork node every turn" gate.
|
|
9
|
+
const REFRESH_SPAWN_THROTTLE_MS = 30_000;
|
|
10
|
+
const REFRESH_LAST_MARKER = "/tmp/sl-refresh.last";
|
|
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,51 +67,6 @@ 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)
|
|
69
|
-
function getCcclubRank() {
|
|
70
|
-
const configPath = join(homedir(), ".ccclub", "config.json");
|
|
71
|
-
if (!existsSync(configPath))
|
|
72
|
-
return null;
|
|
73
|
-
const cacheFile = "/tmp/sl-ccclub-rank";
|
|
74
|
-
let staleData = null;
|
|
75
|
-
if (existsSync(cacheFile)) {
|
|
76
|
-
try {
|
|
77
|
-
const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
|
|
78
|
-
staleData = cached.data ?? null;
|
|
79
|
-
const cacheAge = Date.now() - (cached.timestamp || 0);
|
|
80
|
-
if (cacheAge < CACHE_TTL_MS)
|
|
81
|
-
return staleData;
|
|
82
|
-
}
|
|
83
|
-
catch { }
|
|
84
|
-
}
|
|
85
|
-
try {
|
|
86
|
-
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
87
|
-
const code = config.groups?.[0];
|
|
88
|
-
const userId = config.userId;
|
|
89
|
-
if (!code || !userId)
|
|
90
|
-
return staleData;
|
|
91
|
-
const tz = -(new Date()).getTimezoneOffset();
|
|
92
|
-
const url = `${config.apiUrl}/api/rank/${code}?period=today&tz=${tz}`;
|
|
93
|
-
const response = execSync(`curl -sf "${url}"`, { encoding: "utf-8", timeout: 5000 });
|
|
94
|
-
if (!response)
|
|
95
|
-
return staleData;
|
|
96
|
-
const data = JSON.parse(response);
|
|
97
|
-
const rankings = data.rankings || [];
|
|
98
|
-
const me = rankings.find((r) => r.userId === userId);
|
|
99
|
-
if (!me)
|
|
100
|
-
return staleData;
|
|
101
|
-
const result = { rank: me.rank, total: rankings.length, cost: me.costUSD };
|
|
102
|
-
writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: Date.now() }), "utf-8");
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
try {
|
|
107
|
-
writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: Date.now() }), "utf-8");
|
|
108
|
-
}
|
|
109
|
-
catch { }
|
|
110
|
-
return staleData;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
70
|
export function rankColor(rank) {
|
|
114
71
|
if (rank === 1)
|
|
115
72
|
return FG_YELLOW;
|
|
@@ -119,98 +76,45 @@ export function rankColor(rank) {
|
|
|
119
76
|
return FG_ORANGE;
|
|
120
77
|
return FG_CYAN;
|
|
121
78
|
}
|
|
122
|
-
//
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
let staleData = null;
|
|
128
|
-
if (existsSync(cacheFile)) {
|
|
129
|
-
try {
|
|
130
|
-
const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
|
|
131
|
-
staleData = cached.data ?? null;
|
|
132
|
-
const cacheAge = now - (cached.timestamp || 0);
|
|
133
|
-
if (cacheAge < CACHE_TTL_MS)
|
|
134
|
-
return staleData;
|
|
135
|
-
}
|
|
136
|
-
catch { }
|
|
79
|
+
// Read-only: usage data from /tmp cache. Refresh is done by `cc-costline refresh-bg`.
|
|
80
|
+
function readUsageCache() {
|
|
81
|
+
try {
|
|
82
|
+
const cached = JSON.parse(readFileSync("/tmp/sl-claude-usage", "utf-8"));
|
|
83
|
+
return cached.data ?? null;
|
|
137
84
|
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Read-only: ccclub rank from /tmp cache.
|
|
90
|
+
function readRankCache() {
|
|
138
91
|
try {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
const credentialsJSON = execSync(keychainCmd, { encoding: "utf-8", timeout: 2000 }).trim();
|
|
142
|
-
if (!credentialsJSON)
|
|
143
|
-
return null;
|
|
144
|
-
const credentials = JSON.parse(credentialsJSON);
|
|
145
|
-
const accessToken = credentials.claudeAiOauth?.accessToken;
|
|
146
|
-
if (!accessToken)
|
|
147
|
-
return null;
|
|
148
|
-
const expiresAt = credentials.claudeAiOauth?.expiresAt;
|
|
149
|
-
if (expiresAt && Date.now() / 1000 > expiresAt)
|
|
150
|
-
return null;
|
|
151
|
-
const apiUrl = "https://api.anthropic.com/api/oauth/usage";
|
|
152
|
-
const curlCmd = `curl -sf "${apiUrl}" -H "Authorization: Bearer ${accessToken}" -H "anthropic-beta: oauth-2025-04-20"`;
|
|
153
|
-
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");
|
|
157
|
-
return staleData;
|
|
158
|
-
}
|
|
159
|
-
const data = JSON.parse(response);
|
|
160
|
-
const parseUtil = (val) => {
|
|
161
|
-
if (typeof val === "number")
|
|
162
|
-
return Math.round(val);
|
|
163
|
-
if (typeof val === "string")
|
|
164
|
-
return Math.round(parseFloat(val.replace("%", "")));
|
|
165
|
-
return 0;
|
|
166
|
-
};
|
|
167
|
-
const fiveHour = parseUtil(data.five_hour?.utilization);
|
|
168
|
-
const sevenDay = parseUtil(data.seven_day?.utilization);
|
|
169
|
-
let fiveHourResetsAt;
|
|
170
|
-
// Strategy 1: Use reset time from API if available
|
|
171
|
-
const resetsAtRaw = data.five_hour?.resets_at ?? data.five_hour?.reset_at ?? data.five_hour?.next_reset;
|
|
172
|
-
if (resetsAtRaw) {
|
|
173
|
-
const ts = typeof resetsAtRaw === "string" ? new Date(resetsAtRaw).getTime() : resetsAtRaw * 1000;
|
|
174
|
-
if (!isNaN(ts) && ts > now)
|
|
175
|
-
fiveHourResetsAt = ts;
|
|
176
|
-
}
|
|
177
|
-
// Strategy 2: Fallback - track when we first saw 100%
|
|
178
|
-
if (fiveHour >= 100) {
|
|
179
|
-
if (!fiveHourResetsAt) {
|
|
180
|
-
if (existsSync(hitFile)) {
|
|
181
|
-
const hitTime = parseFloat(readFileSync(hitFile, "utf-8").trim());
|
|
182
|
-
if (!isNaN(hitTime)) {
|
|
183
|
-
fiveHourResetsAt = hitTime + 5 * 3600 * 1000;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
writeFileSync(hitFile, String(now), "utf-8");
|
|
188
|
-
fiveHourResetsAt = now + 5 * 3600 * 1000;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
// Usage dropped below 100%, clear hit tracker
|
|
194
|
-
try {
|
|
195
|
-
if (existsSync(hitFile))
|
|
196
|
-
unlinkSync(hitFile);
|
|
197
|
-
}
|
|
198
|
-
catch { }
|
|
199
|
-
}
|
|
200
|
-
const result = { fiveHour, sevenDay };
|
|
201
|
-
if (fiveHourResetsAt)
|
|
202
|
-
result.fiveHourResetsAt = fiveHourResetsAt;
|
|
203
|
-
writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now }), "utf-8");
|
|
204
|
-
return result;
|
|
92
|
+
const cached = JSON.parse(readFileSync("/tmp/sl-ccclub-rank", "utf-8"));
|
|
93
|
+
return cached.data ?? null;
|
|
205
94
|
}
|
|
206
95
|
catch {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Spawn detached `cc-costline refresh-bg` subprocess. Render does NOT wait for it.
|
|
100
|
+
// The subprocess uses a lockfile to prevent concurrent refresh across multiple Claude Code windows.
|
|
101
|
+
function maybeSpawnRefresh(transcriptPath) {
|
|
102
|
+
if (process.env.CC_COSTLINE_NO_SPAWN)
|
|
103
|
+
return;
|
|
104
|
+
const entry = process.argv[1] || "";
|
|
105
|
+
if (!/cc-costline|cli\.js$/.test(entry))
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const stat = statSync(REFRESH_LAST_MARKER);
|
|
109
|
+
if (Date.now() - stat.mtimeMs < REFRESH_SPAWN_THROTTLE_MS)
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
try {
|
|
114
|
+
const child = spawn(process.execPath, [entry, "refresh-bg", transcriptPath], { detached: true, stdio: "ignore" });
|
|
115
|
+
child.unref();
|
|
213
116
|
}
|
|
117
|
+
catch { }
|
|
214
118
|
}
|
|
215
119
|
export function render(input) {
|
|
216
120
|
let data;
|
|
@@ -222,10 +126,10 @@ export function render(input) {
|
|
|
222
126
|
}
|
|
223
127
|
// Session data from Claude Code stdin
|
|
224
128
|
const cost = data.cost?.total_cost_usd ?? 0;
|
|
225
|
-
const model = data.model?.display_name ?? "—";
|
|
129
|
+
const model = (data.model?.display_name ?? "—").replace(/\s*\((\d+[KMB])\s+context\)/i, " ($1)");
|
|
226
130
|
const contextPct = Math.floor(data.context_window?.used_percentage ?? 0);
|
|
227
131
|
const transcriptPath = data.transcript_path ?? "";
|
|
228
|
-
// Token stats from transcript
|
|
132
|
+
// Token stats from transcript (synchronous — small per-session file, typically < 1ms)
|
|
229
133
|
let totalTokens = 0;
|
|
230
134
|
if (transcriptPath) {
|
|
231
135
|
try {
|
|
@@ -245,22 +149,13 @@ export function render(input) {
|
|
|
245
149
|
}
|
|
246
150
|
catch { }
|
|
247
151
|
}
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
if (shouldRefreshLocalCostCache(cache, transcriptPath)) {
|
|
251
|
-
try {
|
|
252
|
-
const result = collectCosts();
|
|
253
|
-
// Don't overwrite valid cache with zeros (directory read failure)
|
|
254
|
-
if (result.cost7d > 0 || result.cost30d > 0 || !cache) {
|
|
255
|
-
const newCache = { cost7d: result.cost7d, cost30d: result.cost30d, updatedAt: new Date().toISOString() };
|
|
256
|
-
writeCache(newCache);
|
|
257
|
-
cache = newCache;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
catch { }
|
|
261
|
-
}
|
|
152
|
+
// All external data is read-only here. refresh-bg writes these caches in the background.
|
|
153
|
+
const cache = readCache();
|
|
262
154
|
const config = readConfig();
|
|
263
|
-
const claudeUsage =
|
|
155
|
+
const claudeUsage = readUsageCache();
|
|
156
|
+
const ccclubRank = readRankCache();
|
|
157
|
+
// Fire-and-forget background refresh (throttled to once per 30s)
|
|
158
|
+
maybeSpawnRefresh(transcriptPath);
|
|
264
159
|
const g = FG_GRAY_DIM;
|
|
265
160
|
const y = FG_YELLOW;
|
|
266
161
|
const m = FG_MODEL;
|
|
@@ -299,7 +194,6 @@ export function render(input) {
|
|
|
299
194
|
segments.push(usageParts.join(` ${g}·${r} `));
|
|
300
195
|
}
|
|
301
196
|
// #2 $53.6
|
|
302
|
-
const ccclubRank = getCcclubRank();
|
|
303
197
|
if (ccclubRank) {
|
|
304
198
|
const rc = rankColor(ccclubRank.rank);
|
|
305
199
|
segments.push(`${rc}#${ccclubRank.rank} ${formatCost(ccclubRank.cost)}${r}`);
|