@tsa-group/claude-usage 0.3.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.md +216 -0
- package/dist/cli.js +104 -0
- package/dist/creds.js +103 -0
- package/dist/daemon.js +217 -0
- package/dist/events.js +448 -0
- package/dist/health.js +95 -0
- package/dist/install.js +263 -0
- package/dist/paths.js +57 -0
- package/dist/upload.js +188 -0
- package/dist/usage.js +112 -0
- package/dist/util.js +73 -0
- package/dist/version.js +24 -0
- package/dist/view.js +182 -0
- package/package.json +22 -0
package/dist/util.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** 小工具。沒有執行期依賴是刻意的 —— client 裝在同事機器上,每個 dependency
|
|
2
|
+
* 都是一個他們沒同意過的信任關係。 */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
export const nowIso = (d = new Date()) => d.toISOString();
|
|
6
|
+
/** ISO-8601 → Date,壞字串回 null(不丟例外:這些值來自外部 JSON) */
|
|
7
|
+
export function parseIso(s) {
|
|
8
|
+
if (typeof s !== "string" || !s)
|
|
9
|
+
return null;
|
|
10
|
+
const t = Date.parse(s);
|
|
11
|
+
return Number.isNaN(t) ? null : new Date(t);
|
|
12
|
+
}
|
|
13
|
+
/** SHA256(s) 前 16 個十六進位字元。空值回 null —— 與 Python 版一致,
|
|
14
|
+
* 否則 `h16("")` 會變成一個看起來合法的雜湊。 */
|
|
15
|
+
export function h16(s) {
|
|
16
|
+
if (!s)
|
|
17
|
+
return null;
|
|
18
|
+
return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
|
|
19
|
+
}
|
|
20
|
+
export function sha256hex(s) {
|
|
21
|
+
return createHash("sha256").update(s, "utf8").digest("hex");
|
|
22
|
+
}
|
|
23
|
+
export function readJson(path) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Atomic write —— 半寫的狀態檔會讓下一輪漏資料或重讀全部歷史。 */
|
|
32
|
+
export function writeJsonAtomic(path, obj) {
|
|
33
|
+
const tmp = path + ".tmp";
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
|
35
|
+
renameSync(tmp, path);
|
|
36
|
+
}
|
|
37
|
+
/** 千分位,給本機檢視用 */
|
|
38
|
+
export const fmtInt = (n) => n == null ? "-" : Math.round(n).toLocaleString("en-US");
|
|
39
|
+
/** 大數字縮寫:284000000 → 284M。表格用,避免一欄吃掉整行 */
|
|
40
|
+
export function fmtCompact(n) {
|
|
41
|
+
if (n == null)
|
|
42
|
+
return "-";
|
|
43
|
+
const a = Math.abs(n);
|
|
44
|
+
if (a >= 1e9)
|
|
45
|
+
return (n / 1e9).toFixed(1) + "B";
|
|
46
|
+
if (a >= 1e6)
|
|
47
|
+
return (n / 1e6).toFixed(1) + "M";
|
|
48
|
+
if (a >= 1e3)
|
|
49
|
+
return (n / 1e3).toFixed(0) + "K";
|
|
50
|
+
return String(Math.round(n));
|
|
51
|
+
}
|
|
52
|
+
/** 相對時間:in 3h 21m / 2h ago */
|
|
53
|
+
export function humanDelta(iso) {
|
|
54
|
+
const t = parseIso(iso);
|
|
55
|
+
if (!t)
|
|
56
|
+
return "—";
|
|
57
|
+
const ms = t.getTime() - Date.now();
|
|
58
|
+
const past = ms < 0;
|
|
59
|
+
let secs = Math.abs(Math.round(ms / 1000));
|
|
60
|
+
const h = Math.floor(secs / 3600);
|
|
61
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
62
|
+
const s = h ? `${h}h ${m}m` : `${m}m`;
|
|
63
|
+
return past ? `${s} ago` : `in ${s}`;
|
|
64
|
+
}
|
|
65
|
+
/** 本地時區的簡短時間戳 */
|
|
66
|
+
export function localTime(iso, withDate = true) {
|
|
67
|
+
const t = parseIso(iso);
|
|
68
|
+
if (!t)
|
|
69
|
+
return "—";
|
|
70
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
71
|
+
const hm = `${p(t.getHours())}:${p(t.getMinutes())}`;
|
|
72
|
+
return withDate ? `${p(t.getMonth() + 1)}-${p(t.getDate())} ${hm}` : hm;
|
|
73
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent_version 的唯一來源 = package.json。
|
|
3
|
+
*
|
|
4
|
+
* 舊版另外維護一個 VERSION 檔,於是「發佈的版本」與「回報上去的版本」是兩個
|
|
5
|
+
* 可以各自漂移的數字 —— 而 agent_version 正是我們用來判斷「誰還在跑舊 client」
|
|
6
|
+
* 的欄位(server 端的裝置健康報表會顯示它),漂掉就等於這個欄位在說謊。
|
|
7
|
+
*
|
|
8
|
+
* 走執行期讀取而不是建置期產生:npm 打包一定會包含 package.json,且 src/ 與
|
|
9
|
+
* dist/ 到 package.json 的相對路徑相同,所以開發與出貨走的是同一條路徑。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
function read() {
|
|
15
|
+
try {
|
|
16
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
17
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
18
|
+
return pkg.version ?? "unknown";
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const VERSION = read();
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本機檢視。**刻意精簡**:數字全部保留,不做 box-drawing 邊框、進度條與 sparkline。
|
|
3
|
+
*
|
|
4
|
+
* 理由不只是省程式碼 —— 那些字元在 Windows console 與非 UTF-8 終端會變成亂碼,
|
|
5
|
+
* 而這個工具的重點是「同事願意裝、看得懂自己的數字」。真正的儀表在 BQ / Looker
|
|
6
|
+
* Studio,本機檢視只需要回答「我現在燒到哪、我這幾場 session 花了多少」。
|
|
7
|
+
*
|
|
8
|
+
* severity 的燈號有保留:那是 Anthropic 自己的判定(門檻未公開),是資訊不是裝飾。
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import * as events from "./events.js";
|
|
13
|
+
import { lastSnapshot } from "./health.js";
|
|
14
|
+
import { snapPath } from "./paths.js";
|
|
15
|
+
import { fmtCompact, fmtInt, humanDelta, localTime } from "./util.js";
|
|
16
|
+
const sevMark = (s) => ({ normal: "🟢", warning: "🟡", critical: "🔴" })[String(s)] ?? "⚪";
|
|
17
|
+
/**
|
|
18
|
+
* 終端顯示寬度。CJK 與 emoji 在等寬終端佔 **2 格**,而 String.length 算 1 ——
|
|
19
|
+
* 直接用 length 補空白,中文標籤那幾欄就會歪掉(實測「5 小時窗」與「每週(全)」
|
|
20
|
+
* 對不齊)。這是這份精簡檢視唯一需要的排版邏輯。
|
|
21
|
+
*/
|
|
22
|
+
function dispWidth(s) {
|
|
23
|
+
let w = 0;
|
|
24
|
+
for (const ch of s) {
|
|
25
|
+
const c = ch.codePointAt(0);
|
|
26
|
+
const wide = (c >= 0x1100 && c <= 0x115f) || // Hangul Jamo
|
|
27
|
+
(c >= 0x2e80 && c <= 0xa4cf) || // CJK 部首、假名、漢字
|
|
28
|
+
(c >= 0xac00 && c <= 0xd7a3) || // Hangul 音節
|
|
29
|
+
(c >= 0xf900 && c <= 0xfaff) || // CJK 相容漢字
|
|
30
|
+
(c >= 0xfe30 && c <= 0xfe6f) || // CJK 相容形式
|
|
31
|
+
(c >= 0xff00 && c <= 0xff60) || // 全形英數
|
|
32
|
+
(c >= 0xffe0 && c <= 0xffe6) ||
|
|
33
|
+
(c >= 0x1f300 && c <= 0x1faff); // emoji(severity 燈號)
|
|
34
|
+
w += wide ? 2 : 1;
|
|
35
|
+
}
|
|
36
|
+
return w;
|
|
37
|
+
}
|
|
38
|
+
const pad = (s, n) => {
|
|
39
|
+
const gap = n - dispWidth(s);
|
|
40
|
+
return gap > 0 ? s + " ".repeat(gap) : s;
|
|
41
|
+
};
|
|
42
|
+
const padL = (s, n) => {
|
|
43
|
+
const gap = n - dispWidth(s);
|
|
44
|
+
return gap > 0 ? " ".repeat(gap) + s : s;
|
|
45
|
+
};
|
|
46
|
+
/** 最新一筆快照 */
|
|
47
|
+
export function showLatest() {
|
|
48
|
+
const snap = lastSnapshot();
|
|
49
|
+
if (!snap) {
|
|
50
|
+
console.log("尚無快照 — 先跑:claude-usage sample");
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const u = (snap["usage"] ?? {});
|
|
54
|
+
const limits = (Array.isArray(u["limits"]) ? u["limits"] : []);
|
|
55
|
+
const byKind = new Map(limits.map((l) => [String(l["kind"]), l]));
|
|
56
|
+
console.log(`\n採集 ${localTime(snap["collected_at"])} (${humanDelta(snap["collected_at"])}) ` +
|
|
57
|
+
`訂閱=${snap["subscription"] ?? "-"} tier=${snap["tier"] ?? "-"} ` +
|
|
58
|
+
`creds=${snap["cred_source"] ?? "-"}\n`);
|
|
59
|
+
const line = (label, pct, sev, resets, extra = "") => console.log(` ${pad(label, 14)}${padL(pct == null ? "-" : `${pct}%`, 5)} ${sevMark(sev)} ${pad(String(sev ?? "-"), 9)}` +
|
|
60
|
+
`reset ${humanDelta(resets)}${extra}`);
|
|
61
|
+
const fh = (u["five_hour"] ?? {});
|
|
62
|
+
const sd = (u["seven_day"] ?? {});
|
|
63
|
+
const sess = byKind.get("session") ?? {};
|
|
64
|
+
const wall = byKind.get("weekly_all") ?? {};
|
|
65
|
+
const wsc = byKind.get("weekly_scoped");
|
|
66
|
+
line("5 小時窗", fh["utilization"], sess["severity"], fh["resets_at"]);
|
|
67
|
+
line("每週(全)", sd["utilization"], wall["severity"], sd["resets_at"]);
|
|
68
|
+
if (wsc) {
|
|
69
|
+
const scope = (wsc["scope"] ?? {});
|
|
70
|
+
const model = (scope["model"] ?? {})["display_name"];
|
|
71
|
+
// per-model 週上限。它**只存在於 limits[] 裡**,不在 usage.five_hour/seven_day,
|
|
72
|
+
// 是「週額度是不是被單一模型吃掉」的唯一訊號。
|
|
73
|
+
line("每週(單模型)", wsc["percent"], wsc["severity"], wsc["resets_at"], ` [${model ?? "?"}]`);
|
|
74
|
+
}
|
|
75
|
+
const ex = (u["extra_usage"] ?? {});
|
|
76
|
+
console.log(`\n overage: enabled=${ex["is_enabled"]} spend_limit_reached=${ex["spend_limit_reached"]}`);
|
|
77
|
+
console.log(" 註:此為帳號全域額度(含 claude.ai 網頁 chat),與下面的 token 數是兩個獨立指標\n");
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
/** 快照時間序(表格,不畫圖) */
|
|
81
|
+
export function showHistory() {
|
|
82
|
+
const p = snapPath();
|
|
83
|
+
if (!existsSync(p)) {
|
|
84
|
+
console.log("尚無快照");
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const l of readFileSync(p, "utf8").split("\n")) {
|
|
89
|
+
if (!l.trim())
|
|
90
|
+
continue;
|
|
91
|
+
try {
|
|
92
|
+
rows.push(JSON.parse(l));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!rows.length) {
|
|
99
|
+
console.log("快照檔是空的");
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
console.log(`\n${rows.length} 筆快照\n`);
|
|
103
|
+
console.log(` ${pad("時間", 16)}${padL("5h%", 6)}${padL("week%", 7)} 5h reset`);
|
|
104
|
+
let prev = null;
|
|
105
|
+
for (const r of rows) {
|
|
106
|
+
const u = (r["usage"] ?? {});
|
|
107
|
+
const fh = (u["five_hour"] ?? {});
|
|
108
|
+
const sd = (u["seven_day"] ?? {});
|
|
109
|
+
const fv = Number(fh["utilization"] ?? 0);
|
|
110
|
+
const wv = Number(sd["utilization"] ?? 0);
|
|
111
|
+
// 下降 = 窗 reset 了(窗內 utilization 單調遞增),標出來讓人看得到窗邊界
|
|
112
|
+
const trend = prev === null ? "" : fv > prev ? ` +${(fv - prev).toFixed(0)}` : fv < prev ? " reset" : "";
|
|
113
|
+
console.log(` ${pad(localTime(r["collected_at"]), 16)}${padL(fv.toFixed(0), 6)}${padL(wv.toFixed(0), 7)} ` +
|
|
114
|
+
`${localTime(fh["resets_at"])}${trend}`);
|
|
115
|
+
prev = fv;
|
|
116
|
+
}
|
|
117
|
+
console.log();
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
/** session / token 明細 */
|
|
121
|
+
export function showSessions(argv = []) {
|
|
122
|
+
const ev = events.extract();
|
|
123
|
+
const agg = new Map();
|
|
124
|
+
for (const e of ev) {
|
|
125
|
+
const sid = e.session_id;
|
|
126
|
+
if (!sid)
|
|
127
|
+
continue;
|
|
128
|
+
let s = agg.get(sid);
|
|
129
|
+
if (!s) {
|
|
130
|
+
s = {
|
|
131
|
+
id: sid, first: e.ts, last: e.ts, msgs: 0, sub: 0, models: new Set(),
|
|
132
|
+
input: 0, output: 0, cacheRead: 0, w5m: 0, w1h: 0,
|
|
133
|
+
};
|
|
134
|
+
agg.set(sid, s);
|
|
135
|
+
}
|
|
136
|
+
if (e.ts < s.first)
|
|
137
|
+
s.first = e.ts;
|
|
138
|
+
if (e.ts > s.last)
|
|
139
|
+
s.last = e.ts;
|
|
140
|
+
s.msgs++;
|
|
141
|
+
if (e.is_sidechain)
|
|
142
|
+
s.sub++;
|
|
143
|
+
if (e.model)
|
|
144
|
+
s.models.add(e.model);
|
|
145
|
+
s.input += e.input_tokens ?? 0;
|
|
146
|
+
s.output += e.output_tokens ?? 0;
|
|
147
|
+
s.cacheRead += e.cache_read_tokens ?? 0;
|
|
148
|
+
s.w5m += e.cache_write_5m_tokens ?? 0;
|
|
149
|
+
s.w1h += e.cache_write_1h_tokens ?? 0;
|
|
150
|
+
}
|
|
151
|
+
const recs = [...agg.values()].sort((a, b) => (a.first < b.first ? -1 : 1));
|
|
152
|
+
if (argv.includes("--json")) {
|
|
153
|
+
console.log(JSON.stringify({ schema: "sessions.v2", sessions: recs.map((r) => ({ ...r, models: [...r.models].sort() })) }, null, 2));
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const hdr = ` ${pad("session", 14)}${pad("start", 14)}${padL("msgs", 6)}${padL("sub", 5)}` +
|
|
157
|
+
`${padL("in", 8)}${padL("out", 8)}${padL("cache_rd", 10)}${padL("cache_wr", 10)} models`;
|
|
158
|
+
console.log("\n" + hdr);
|
|
159
|
+
console.log(" " + "-".repeat(hdr.length - 2));
|
|
160
|
+
const tot = { msgs: 0, sub: 0, input: 0, output: 0, cacheRead: 0, w5m: 0, w1h: 0 };
|
|
161
|
+
for (const r of recs) {
|
|
162
|
+
tot.msgs += r.msgs;
|
|
163
|
+
tot.sub += r.sub;
|
|
164
|
+
tot.input += r.input;
|
|
165
|
+
tot.output += r.output;
|
|
166
|
+
tot.cacheRead += r.cacheRead;
|
|
167
|
+
tot.w5m += r.w5m;
|
|
168
|
+
tot.w1h += r.w1h;
|
|
169
|
+
console.log(` ${pad(r.id.slice(0, 12), 14)}${pad(localTime(r.first), 14)}${padL(String(r.msgs), 6)}` +
|
|
170
|
+
`${padL(String(r.sub), 5)}${padL(fmtCompact(r.input), 8)}${padL(fmtCompact(r.output), 8)}` +
|
|
171
|
+
`${padL(fmtCompact(r.cacheRead), 10)}${padL(fmtCompact(r.w5m + r.w1h), 10)} ` +
|
|
172
|
+
[...r.models].map((m) => m.replace("claude-", "")).sort().join(","));
|
|
173
|
+
}
|
|
174
|
+
console.log(" " + "-".repeat(hdr.length - 2));
|
|
175
|
+
console.log(` ${pad("TOTAL", 14)}${pad(`${recs.length} sessions`, 14)}${padL(String(tot.msgs), 6)}` +
|
|
176
|
+
`${padL(String(tot.sub), 5)}${padL(fmtCompact(tot.input), 8)}${padL(fmtCompact(tot.output), 8)}` +
|
|
177
|
+
`${padL(fmtCompact(tot.cacheRead), 10)}${padL(fmtCompact(tot.w5m + tot.w1h), 10)}`);
|
|
178
|
+
console.log(`\n cache_write 拆開:5m=${fmtInt(tot.w5m)} (base x1.25) 1h=${fmtInt(tot.w1h)} (base x2)`);
|
|
179
|
+
console.log(" sub = subagent 訊息數");
|
|
180
|
+
console.log(" 刻意不印 tokens 總和:cache_read 單價只有 base 的 0.1x,加總後會被它完全主導而失去意義\n");
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tsa-group/claude-usage",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Per-user Claude usage collector — measures Claude Code token detail and account-level rate-limit utilization locally, reports to your own ingest server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "claude-usage": "dist/cli.js" },
|
|
7
|
+
"files": ["dist", "README.md"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"test": "node --test test/*.test.ts",
|
|
12
|
+
"prepublishOnly": "npm run build && npm test",
|
|
13
|
+
"check": "tsc -p tsconfig.json --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["claude", "usage", "telemetry", "cli"],
|
|
16
|
+
"license": "UNLICENSED",
|
|
17
|
+
"private": false,
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^24.0.0",
|
|
20
|
+
"typescript": "^5.9.0"
|
|
21
|
+
}
|
|
22
|
+
}
|