@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/install.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 平台安裝層 — 把 `claude-usage daemon-tick` 註冊成背景任務。
|
|
3
|
+
* **唯一有 per-OS 分支的地方**;核心邏輯(daemon.tick)三個平台共用。
|
|
4
|
+
*
|
|
5
|
+
* macOS : launchd LaunchAgent (~/Library/LaunchAgents)
|
|
6
|
+
* Windows : 工作排程器 schtasks(per-user,每 N 分鐘)—— 碼寫好但**未在真機實測**
|
|
7
|
+
* Linux : systemd --user timer(未實作)
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { claudeSettingsPath, devicePath, logPath, serverUrl, stateDir } from "./paths.js";
|
|
15
|
+
import { printHealth } from "./daemon.js";
|
|
16
|
+
import { enroll, enrollSecret } from "./upload.js";
|
|
17
|
+
import { readJson, writeJsonAtomic } from "./util.js";
|
|
18
|
+
const LABEL = "com.tsa.claude-usage";
|
|
19
|
+
const WIN_TASK = "ClaudeUsage";
|
|
20
|
+
/** 排程器基礎頻率;真正的採樣間隔由 tick() 自己決定(見 daemon.decide) */
|
|
21
|
+
const BASE_INTERVAL_SEC = 300;
|
|
22
|
+
/**
|
|
23
|
+
* 背景任務要執行的東西。用 `process.execPath`(node 絕對路徑)+ cli.js 絕對路徑,
|
|
24
|
+
* 不用 PATH 上的 `claude-usage` shim —— launchd / schtasks 的 PATH 極簡,找不到 shim。
|
|
25
|
+
*
|
|
26
|
+
* `npm update` 會就地換掉套件檔案、路徑不變,所以升級後排程仍然有效。若使用者換掉
|
|
27
|
+
* node(例如 nvm 切版本後刪舊版)則會失效 —— 那會表現成 v_device_health 的
|
|
28
|
+
* `no_heartbeat`,看得見,重跑一次 install 即可。
|
|
29
|
+
*/
|
|
30
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "cli.js");
|
|
31
|
+
const NODE = process.execPath;
|
|
32
|
+
const HOOK_CMD = `"${NODE}" "${CLI}" sample --hook`;
|
|
33
|
+
const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
34
|
+
const isDry = (argv) => argv.includes("--dry-run");
|
|
35
|
+
// ─────────────────────────────── macOS ───────────────────────────────
|
|
36
|
+
const plistBody = () => `<?xml version="1.0" encoding="UTF-8"?>
|
|
37
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
38
|
+
<plist version="1.0">
|
|
39
|
+
<dict>
|
|
40
|
+
<key>Label</key><string>${LABEL}</string>
|
|
41
|
+
<key>ProgramArguments</key>
|
|
42
|
+
<array>
|
|
43
|
+
<string>${NODE}</string>
|
|
44
|
+
<string>${CLI}</string>
|
|
45
|
+
<string>daemon-tick</string>
|
|
46
|
+
</array>
|
|
47
|
+
<key>StartInterval</key><integer>${BASE_INTERVAL_SEC}</integer>
|
|
48
|
+
<key>RunAtLoad</key><true/>
|
|
49
|
+
<key>ProcessType</key><string>Background</string>
|
|
50
|
+
<key>StandardOutPath</key><string>${logPath()}</string>
|
|
51
|
+
<key>StandardErrorPath</key><string>${logPath()}</string>
|
|
52
|
+
</dict>
|
|
53
|
+
</plist>
|
|
54
|
+
`;
|
|
55
|
+
function macInstall() {
|
|
56
|
+
mkdirSync(dirname(PLIST_PATH), { recursive: true });
|
|
57
|
+
writeFileSync(PLIST_PATH, plistBody(), "utf8");
|
|
58
|
+
const uid = userInfo().uid;
|
|
59
|
+
// 現代 launchd:bootstrap 進 per-user GUI domain。先 bootout 讓它可重複執行。
|
|
60
|
+
spawnSync("launchctl", ["bootout", `gui/${uid}`, PLIST_PATH], { stdio: "ignore" });
|
|
61
|
+
const r = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, PLIST_PATH], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
});
|
|
64
|
+
console.log(`wrote ${PLIST_PATH}`);
|
|
65
|
+
console.log(`launchctl bootstrap: rc=${r.status} ${(r.stderr ?? "").trim()}`);
|
|
66
|
+
console.log(`base cadence ${BASE_INTERVAL_SEC}s · RunAtLoad=true · log -> ${logPath()}`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
function macUninstall() {
|
|
70
|
+
const uid = userInfo().uid;
|
|
71
|
+
spawnSync("launchctl", ["bootout", `gui/${uid}`, PLIST_PATH], { stdio: "ignore" });
|
|
72
|
+
if (existsSync(PLIST_PATH))
|
|
73
|
+
rmSync(PLIST_PATH);
|
|
74
|
+
console.log(`removed ${PLIST_PATH} and unloaded from launchd`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
function macStatus() {
|
|
78
|
+
const uid = userInfo().uid;
|
|
79
|
+
const r = spawnSync("launchctl", ["print", `gui/${uid}/${LABEL}`], { encoding: "utf8" });
|
|
80
|
+
if (r.status !== 0) {
|
|
81
|
+
console.log("bg-task: NOT installed in launchd");
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
for (const line of (r.stdout ?? "").split("\n")) {
|
|
85
|
+
const s = line.trim();
|
|
86
|
+
if (/^(state|pid|runs|last exit code) =/.test(s))
|
|
87
|
+
console.log(" " + s);
|
|
88
|
+
}
|
|
89
|
+
console.log(`plist: ${existsSync(PLIST_PATH) ? "present" : "MISSING"}`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
// ────────────────────────────── Windows ──────────────────────────────
|
|
93
|
+
// ⚠️ 以下未在真機實測(見 README「已知限制」)。
|
|
94
|
+
/** 隱藏視窗的啟動器:node 沒有 pythonw 那種無 console 的變體,用 VBS 包一層是標準做法。 */
|
|
95
|
+
const VBS_PATH = () => join(stateDir(), "run-hidden.vbs");
|
|
96
|
+
function winInstall() {
|
|
97
|
+
const vbs = `Set s = CreateObject("Wscript.Shell")\r\n` +
|
|
98
|
+
`s.Run """${NODE}"" ""${CLI}"" daemon-tick", 0, False\r\n`;
|
|
99
|
+
writeFileSync(VBS_PATH(), vbs, "utf8");
|
|
100
|
+
const tr = `wscript.exe //B "${VBS_PATH()}"`;
|
|
101
|
+
const args = [
|
|
102
|
+
"/Create", "/TN", WIN_TASK,
|
|
103
|
+
"/SC", "MINUTE", "/MO", String(Math.max(1, Math.floor(BASE_INTERVAL_SEC / 60))),
|
|
104
|
+
"/TR", tr,
|
|
105
|
+
"/RL", "LIMITED", "/F",
|
|
106
|
+
];
|
|
107
|
+
const r = spawnSync("schtasks", args, { encoding: "utf8" });
|
|
108
|
+
console.log(`wrote ${VBS_PATH()}`);
|
|
109
|
+
console.log(`schtasks /Create: rc=${r.status} ${(r.stdout ?? r.stderr ?? "").trim()}`);
|
|
110
|
+
console.log(`uninstall: schtasks /Delete /TN ${WIN_TASK} /F`);
|
|
111
|
+
console.log("! Windows 路徑尚未在真機驗證過 —— 裝完請跑 claude-usage status 確認有心跳");
|
|
112
|
+
return r.status ?? 1;
|
|
113
|
+
}
|
|
114
|
+
function winUninstall() {
|
|
115
|
+
spawnSync("schtasks", ["/Delete", "/TN", WIN_TASK, "/F"], { stdio: "inherit" });
|
|
116
|
+
if (existsSync(VBS_PATH()))
|
|
117
|
+
rmSync(VBS_PATH());
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
function winStatus() {
|
|
121
|
+
const r = spawnSync("schtasks", ["/Query", "/TN", WIN_TASK], { encoding: "utf8" });
|
|
122
|
+
console.log(r.status === 0 ? (r.stdout ?? "").trim() : "bg-task: NOT installed (schtasks)");
|
|
123
|
+
return r.status ?? 1;
|
|
124
|
+
}
|
|
125
|
+
const sessionStartGroups = (s) => s.hooks?.SessionStart ?? [];
|
|
126
|
+
/**
|
|
127
|
+
* 這條 command 是不是「某個版本的本工具採樣 hook」。
|
|
128
|
+
*
|
|
129
|
+
* 比對必須**解析 JSON 之後比對 command 字串**,不能拿 HOOK_CMD 去 substring 比
|
|
130
|
+
* settings.json 原文:HOOK_CMD 含 `"` 而檔案裡是轉義的 `\"`,永遠比不中 → 誤報
|
|
131
|
+
* not registered → 害人重跑 install 裝出重複的 hook。這個 bug 真的發生過。
|
|
132
|
+
*/
|
|
133
|
+
function isOurSamplingHook(cmd) {
|
|
134
|
+
if (!cmd)
|
|
135
|
+
return false;
|
|
136
|
+
if (cmd.includes(CLI))
|
|
137
|
+
return true; // 現行這一份
|
|
138
|
+
// 歷史版本:Python client(claude-usage 腳本 / poc 的 fetch_usage.py)
|
|
139
|
+
if (cmd.includes("fetch_usage.py"))
|
|
140
|
+
return true;
|
|
141
|
+
if (cmd.includes("claude-usage") && cmd.includes("sample"))
|
|
142
|
+
return true;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
export function registerHook(argv = []) {
|
|
146
|
+
const path = claudeSettingsPath();
|
|
147
|
+
const s = readJson(path) ?? {};
|
|
148
|
+
s.hooks ??= {};
|
|
149
|
+
const groups = sessionStartGroups(s);
|
|
150
|
+
// 先掃掉**所有**舊版採樣 hook 再裝新的。只做「有沒有裝過」的檢查是不夠的:
|
|
151
|
+
// 從 Python 版升上來時舊 hook 還在,兩個都會在開 session 時各打一次 API。
|
|
152
|
+
const stale = [];
|
|
153
|
+
const kept = [];
|
|
154
|
+
for (const g of groups) {
|
|
155
|
+
const hooks = (g.hooks ?? []).filter((h) => {
|
|
156
|
+
const cmd = h.command ?? "";
|
|
157
|
+
if (cmd !== HOOK_CMD && isOurSamplingHook(cmd)) {
|
|
158
|
+
stale.push(cmd);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
});
|
|
163
|
+
if (hooks.length)
|
|
164
|
+
kept.push({ ...g, hooks });
|
|
165
|
+
}
|
|
166
|
+
const already = kept.some((g) => (g.hooks ?? []).some((h) => h.command === HOOK_CMD));
|
|
167
|
+
if (isDry(argv)) {
|
|
168
|
+
for (const c of stale)
|
|
169
|
+
console.log(`hook: WOULD remove stale -> ${c.slice(0, 100)}`);
|
|
170
|
+
console.log(already ? "hook: already registered" : `hook: WOULD add SessionStart -> ${HOOK_CMD}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!already)
|
|
174
|
+
kept.push({ hooks: [{ type: "command", command: HOOK_CMD, timeout: 12 }] });
|
|
175
|
+
s.hooks["SessionStart"] = kept;
|
|
176
|
+
writeJsonAtomic(path, s);
|
|
177
|
+
for (const c of stale)
|
|
178
|
+
console.log(`hook: removed stale -> ${c.slice(0, 100)}`);
|
|
179
|
+
console.log(already ? "hook: already registered" : `hook: registered SessionStart in ${path}`);
|
|
180
|
+
}
|
|
181
|
+
export function unregisterHook() {
|
|
182
|
+
const path = claudeSettingsPath();
|
|
183
|
+
const s = readJson(path);
|
|
184
|
+
if (!s)
|
|
185
|
+
return;
|
|
186
|
+
const kept = [];
|
|
187
|
+
for (const g of sessionStartGroups(s)) {
|
|
188
|
+
const hooks = (g.hooks ?? []).filter((h) => !isOurSamplingHook(h.command ?? ""));
|
|
189
|
+
if (hooks.length)
|
|
190
|
+
kept.push({ ...g, hooks });
|
|
191
|
+
}
|
|
192
|
+
s.hooks ??= {};
|
|
193
|
+
s.hooks["SessionStart"] = kept;
|
|
194
|
+
writeJsonAtomic(path, s);
|
|
195
|
+
console.log("hook: removed from settings.json");
|
|
196
|
+
}
|
|
197
|
+
// ──────────────────────────── dispatch ────────────────────────────
|
|
198
|
+
async function enrollStep(argv) {
|
|
199
|
+
if (existsSync(devicePath())) {
|
|
200
|
+
console.log("enroll: already enrolled (device.json present)");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (isDry(argv)) {
|
|
204
|
+
console.log(`enroll: WOULD POST /api/oauth/profile -> ${serverUrl()}/v1/enroll`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
if ((await enroll(argv)) !== 0)
|
|
209
|
+
throw new Error("enroll returned non-zero");
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
console.error(`enroll: FAILED (${e.message}) — hook/背景任務仍會安裝,下次 report 會自動重試`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function install(argv = []) {
|
|
216
|
+
const os = platform();
|
|
217
|
+
console.log(`claude-usage install (${os})${isDry(argv) ? " [DRY-RUN]" : ""}`);
|
|
218
|
+
console.log(` server = ${serverUrl()} state = ${stateDir()}`);
|
|
219
|
+
if (!enrollSecret(argv) && !existsSync(devicePath())) {
|
|
220
|
+
console.log(" ! server 若有設 enroll 密語,請加 --enroll-secret <密語>");
|
|
221
|
+
}
|
|
222
|
+
await enrollStep(argv);
|
|
223
|
+
registerHook(argv);
|
|
224
|
+
if (isDry(argv)) {
|
|
225
|
+
console.log("bg-task: WOULD register " +
|
|
226
|
+
(os === "darwin" ? "launchd" : os === "win32" ? "schtasks" : "systemd (未實作)"));
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
if (os === "darwin")
|
|
230
|
+
return macInstall();
|
|
231
|
+
if (os === "win32")
|
|
232
|
+
return winInstall();
|
|
233
|
+
console.log(`${os}: systemd --user timer 尚未實作`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
export function uninstall() {
|
|
237
|
+
unregisterHook();
|
|
238
|
+
const os = platform();
|
|
239
|
+
if (os === "darwin")
|
|
240
|
+
return macUninstall();
|
|
241
|
+
if (os === "win32")
|
|
242
|
+
return winUninstall();
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
export function status() {
|
|
246
|
+
const os = platform();
|
|
247
|
+
console.log(`state dir: ${stateDir()}`);
|
|
248
|
+
console.log(`server: ${serverUrl()}`);
|
|
249
|
+
console.log(`enrolled: ${existsSync(devicePath()) ? "yes" : "no"}`);
|
|
250
|
+
const s = readJson(claudeSettingsPath()) ?? {};
|
|
251
|
+
const cmds = sessionStartGroups(s).flatMap((g) => (g.hooks ?? []).map((h) => h.command ?? ""));
|
|
252
|
+
const ours = cmds.filter((c) => c === HOOK_CMD);
|
|
253
|
+
const others = cmds.filter((c) => c !== HOOK_CMD && isOurSamplingHook(c));
|
|
254
|
+
console.log(`hook: ${ours.length ? "registered" : "not registered"}` +
|
|
255
|
+
(others.length ? ` (+${others.length} 個其他/過時的採樣 hook)` : ""));
|
|
256
|
+
for (const c of others)
|
|
257
|
+
console.log(` ! 過時或重複: ${c.slice(0, 110)}`);
|
|
258
|
+
// 「背景任務已註冊且 exit 0」不等於「有資料進來」—— 2026-08-22 的事故就是任務照跑、
|
|
259
|
+
// log 照寫,但 17.6 小時零快照。所以這裡量測資料本身。
|
|
260
|
+
const unhealthy = printHealth() !== 0;
|
|
261
|
+
const rc = os === "darwin" ? macStatus() : os === "win32" ? winStatus() : 1;
|
|
262
|
+
return rc || (unhealthy ? 1 : 0);
|
|
263
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共用路徑 + 設定。執行期狀態存在使用者家目錄,**不是安裝目錄** —— npm 全域安裝
|
|
3
|
+
* 目錄可能唯讀、也可能在 `npm update` 時被整個換掉。
|
|
4
|
+
* Windows 走 %USERPROFILE%\.claude-usage。
|
|
5
|
+
*
|
|
6
|
+
* ★ 這裡刻意全部是**函式**而不是模組層常數。Python 版是 `STATE_DIR = os.environ.get(...)`
|
|
7
|
+
* 在 import 時求值,測試只要在 import 前設環境變數就能改指向;ESM 的 import 會被
|
|
8
|
+
* 提升,測試沒有「import 之前」可用。做成函式後測試可以隨時 `process.env.
|
|
9
|
+
* CLAUDE_USAGE_HOME = tmp` 再呼叫 —— 否則每個測試檔都得靠子行程隔離。
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { readJson, writeJsonAtomic } from "./util.js";
|
|
15
|
+
const ensured = new Set();
|
|
16
|
+
export function stateDir() {
|
|
17
|
+
const d = process.env["CLAUDE_USAGE_HOME"] || join(homedir(), ".claude-usage");
|
|
18
|
+
if (!ensured.has(d)) {
|
|
19
|
+
mkdirSync(d, { recursive: true });
|
|
20
|
+
ensured.add(d);
|
|
21
|
+
}
|
|
22
|
+
return d;
|
|
23
|
+
}
|
|
24
|
+
/** 額度快照(本機 append-only JSONL,上報的來源) */
|
|
25
|
+
export const snapPath = () => join(stateDir(), "usage_snapshots.jsonl");
|
|
26
|
+
/** device_id + device_key。**絕不含 Claude token** */
|
|
27
|
+
export const devicePath = () => join(stateDir(), "device.json");
|
|
28
|
+
/** { server_url } */
|
|
29
|
+
export const configPath = () => join(stateDir(), "config.json");
|
|
30
|
+
/** daemon 自身心跳/失敗狀態 */
|
|
31
|
+
export const healthPath = () => join(stateDir(), "health.json");
|
|
32
|
+
/** 上報游標:每個 JSONL 檔讀到第幾個 byte */
|
|
33
|
+
export const cursorPath = () => join(stateDir(), "cursor.json");
|
|
34
|
+
/** daemon 的 stdout/stderr(背景任務寫的) */
|
|
35
|
+
export const logPath = () => join(stateDir(), "daemon.log");
|
|
36
|
+
/** Claude Code 的 OAuth 憑證檔。**唯讀、永不上傳**(且在 mac 上只是過時副本,見 creds.ts) */
|
|
37
|
+
export const credsPath = () => join(homedir(), ".claude", ".credentials.json");
|
|
38
|
+
/** Claude Code 的 session JSONL 根目錄。唯讀 */
|
|
39
|
+
export const projectsDir = () => join(homedir(), ".claude", "projects");
|
|
40
|
+
/** Claude Code 設定(SessionStart hook 註冊在這) */
|
|
41
|
+
export const claudeSettingsPath = () => join(homedir(), ".claude", "settings.json");
|
|
42
|
+
export const DEFAULT_SERVER = "http://127.0.0.1:8787";
|
|
43
|
+
export function config() {
|
|
44
|
+
return readJson(configPath()) ?? {};
|
|
45
|
+
}
|
|
46
|
+
export function serverUrl() {
|
|
47
|
+
return config().server_url || DEFAULT_SERVER;
|
|
48
|
+
}
|
|
49
|
+
export function setConfig(patch) {
|
|
50
|
+
const c = { ...config(), ...patch };
|
|
51
|
+
for (const k of Object.keys(c)) {
|
|
52
|
+
if (c[k] === undefined || c[k] === null)
|
|
53
|
+
delete c[k];
|
|
54
|
+
}
|
|
55
|
+
writeJsonAtomic(configPath(), c);
|
|
56
|
+
return c;
|
|
57
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client uploader — enroll 一次,之後**增量**回報給 ingest server。
|
|
3
|
+
*
|
|
4
|
+
* 永不傳送 Claude token;只送身份(來自 profile)與數字。
|
|
5
|
+
*
|
|
6
|
+
* 四個設計約束:
|
|
7
|
+
*
|
|
8
|
+
* 1. **憑證讀 Keychain**(透過 creds.loadToken)。舊版直接讀 .credentials.json ——
|
|
9
|
+
* 那是過時的 legacy 副本,實測可過期 17.6 小時而使用者完全無感。
|
|
10
|
+
*
|
|
11
|
+
* 2. **游標只在上傳成功(HTTP 200)後才前進**。先存游標再上傳失敗 = 永久丟掉那批
|
|
12
|
+
* 資料,本機 JSONL 是唯一來源,沒有第二次機會。
|
|
13
|
+
*
|
|
14
|
+
* 3. **client 不自報 user_id**。身份由 server 從 device_key 反查。client 自報
|
|
15
|
+
* email/org 是已知的偽造漏洞,送得越少越好。
|
|
16
|
+
*
|
|
17
|
+
* 4. **沒有新資料時仍送 health-only 心跳**。否則「機器閒置」與「採樣壞掉」在 server
|
|
18
|
+
* 眼中一模一樣(都是沒有新資料),而後者正是 2026-08-22 靜默 17.6 小時的形狀。
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { platform, release } from "node:os";
|
|
23
|
+
import { authHeaders, loadToken } from "./creds.js";
|
|
24
|
+
import * as events from "./events.js";
|
|
25
|
+
import { devicePath, serverUrl } from "./paths.js";
|
|
26
|
+
import { readJson, writeJsonAtomic } from "./util.js";
|
|
27
|
+
import { VERSION } from "./version.js";
|
|
28
|
+
async function post(path, obj, bearer) {
|
|
29
|
+
const headers = { "Content-Type": "application/json" };
|
|
30
|
+
if (bearer)
|
|
31
|
+
headers["Authorization"] = `Bearer ${bearer}`;
|
|
32
|
+
try {
|
|
33
|
+
const r = await fetch(serverUrl() + path, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers,
|
|
36
|
+
body: JSON.stringify(obj),
|
|
37
|
+
signal: AbortSignal.timeout(30_000),
|
|
38
|
+
});
|
|
39
|
+
const text = await r.text();
|
|
40
|
+
let body = {};
|
|
41
|
+
try {
|
|
42
|
+
body = text ? JSON.parse(text) : {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
body = { error: text.slice(0, 300) };
|
|
46
|
+
}
|
|
47
|
+
return { status: r.status, body };
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
// status 0 = 根本沒連上(離線 / server 掛了)。與 4xx/5xx 分開,呼叫端才知道
|
|
51
|
+
// 這是「該重試」還是「該修設定」。
|
|
52
|
+
return { status: 0, body: { error: `${e.name}: ${e.message}` } };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ─────────────────────────────── enroll ───────────────────────────────
|
|
56
|
+
/**
|
|
57
|
+
* /api/oauth/profile。token 走 creds.loadToken(Keychain 優先)。
|
|
58
|
+
*
|
|
59
|
+
* `organization.seat_tier` 是**組織方案層級**(team_tier_1),Keychain 的
|
|
60
|
+
* `rateLimitTier` 才是**個人實際額度層級**(default_claude_max_5x)—— 那個才是
|
|
61
|
+
* utilization% 的分母,做席次判讀要用它。兩個欄位語意不同,別混。
|
|
62
|
+
*/
|
|
63
|
+
async function fetchProfile() {
|
|
64
|
+
const tok = loadToken();
|
|
65
|
+
const r = await fetch("https://api.anthropic.com/api/oauth/profile", {
|
|
66
|
+
headers: authHeaders(tok.token),
|
|
67
|
+
signal: AbortSignal.timeout(20_000),
|
|
68
|
+
});
|
|
69
|
+
if (!r.ok)
|
|
70
|
+
throw new Error(`/api/oauth/profile -> HTTP ${r.status}`);
|
|
71
|
+
return { profile: (await r.json()), tok };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 管理者發的共享密語(server 端 CU_ENROLL_SECRET)。
|
|
75
|
+
*
|
|
76
|
+
* **刻意不寫進 config.json。** enroll 是一次性動作,把密語落地在每台機器上只是
|
|
77
|
+
* 多開一個洩漏面,換不到任何東西 —— 需要重新 enroll 時再問管理者一次即可。
|
|
78
|
+
*/
|
|
79
|
+
export function enrollSecret(argv = []) {
|
|
80
|
+
const i = argv.indexOf("--enroll-secret");
|
|
81
|
+
if (i >= 0 && argv[i + 1])
|
|
82
|
+
return argv[i + 1];
|
|
83
|
+
return process.env["CLAUDE_USAGE_ENROLL_SECRET"] || null;
|
|
84
|
+
}
|
|
85
|
+
export async function enroll(argv = []) {
|
|
86
|
+
const { profile, tok } = await fetchProfile();
|
|
87
|
+
const dev = readJson(devicePath()) ?? {};
|
|
88
|
+
const deviceId = dev.device_id || randomUUID(); // 隨機,非 hostname(不洩漏人名)
|
|
89
|
+
const { status, body } = await post("/v1/enroll", {
|
|
90
|
+
profile,
|
|
91
|
+
device_id: deviceId,
|
|
92
|
+
os: platform(),
|
|
93
|
+
os_version: release(),
|
|
94
|
+
agent_version: VERSION,
|
|
95
|
+
install_channel: "npm",
|
|
96
|
+
enroll_secret: enrollSecret(argv),
|
|
97
|
+
// client 自動回報個人額度層級 -> 填 users_seat,讓 utilization% 有分母。
|
|
98
|
+
// source 標明來源;管理端匯出可作為覆寫層(client 自報可偽造)。
|
|
99
|
+
seat: { seat_tier: tok.tier, subscription: tok.subscription, source: tok.source },
|
|
100
|
+
});
|
|
101
|
+
if (status !== 200) {
|
|
102
|
+
console.error(`enroll failed ${status}: ${JSON.stringify(body)}`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
writeJsonAtomic(devicePath(), {
|
|
106
|
+
device_id: body["device_id"],
|
|
107
|
+
device_key: body["device_key"],
|
|
108
|
+
});
|
|
109
|
+
const acct = (profile["account"] ?? {});
|
|
110
|
+
const org = (profile["organization"] ?? {});
|
|
111
|
+
console.log(`enrolled: user=${acct["email"]} org=${org["name"]}`);
|
|
112
|
+
console.log(` seat_tier=${tok.tier} subscription=${tok.subscription} (creds_from=${tok.source})`);
|
|
113
|
+
console.log(` device_id=${body["device_id"]} device_key stored (no claude token)`);
|
|
114
|
+
if (body["status"] === "pending") {
|
|
115
|
+
console.log(` ! ${body["note"] ?? "待管理者核准後才會收下回報"}`);
|
|
116
|
+
}
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* full=true 忽略游標整包重送。重送是安全的:BQ 是 append-only + stg view 去重,
|
|
121
|
+
* 實測 raw 7,203 列而去重 view 穩定在 842 → ingest 可以無腦重試,不需要冪等鍵。
|
|
122
|
+
*/
|
|
123
|
+
export function buildPayload(state, full = false) {
|
|
124
|
+
const filesCur = full ? {} : state.files;
|
|
125
|
+
const snapOff = full ? 0 : state.snapshots;
|
|
126
|
+
const { events: ev, newCursor } = events.extractIncremental(filesCur);
|
|
127
|
+
const snaps = events.snapshotsIncremental(snapOff);
|
|
128
|
+
return {
|
|
129
|
+
payload: {
|
|
130
|
+
agent_version: VERSION,
|
|
131
|
+
limit_snapshots: snaps.rows,
|
|
132
|
+
sessions: events.sessionsFrom(ev),
|
|
133
|
+
token_events: ev,
|
|
134
|
+
// health 不走游標:它是**當下狀態**而不是事件流,每次都送最新的一份。
|
|
135
|
+
health: events.healthPayload(),
|
|
136
|
+
},
|
|
137
|
+
newState: { files: newCursor, snapshots: snaps.offset },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export async function report(argv = []) {
|
|
141
|
+
const full = argv.includes("--full");
|
|
142
|
+
const dry = argv.includes("--dry-run");
|
|
143
|
+
if (!existsSync(devicePath()) && !dry) {
|
|
144
|
+
console.error("not enrolled — run: claude-usage enroll");
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const state = events.loadCursor();
|
|
148
|
+
const { payload, newState } = buildPayload(state, full);
|
|
149
|
+
const n = {
|
|
150
|
+
limit_snapshots: payload.limit_snapshots.length,
|
|
151
|
+
sessions: payload.sessions.length,
|
|
152
|
+
token_events: payload.token_events.length,
|
|
153
|
+
};
|
|
154
|
+
const hasHealth = payload.health !== null;
|
|
155
|
+
if (dry) {
|
|
156
|
+
console.log(`[dry-run] would send: ${JSON.stringify(n)} health=${hasHealth ? "yes" : "no"} (full=${full})`);
|
|
157
|
+
console.log(`[dry-run] cursor would advance: snapshots ${state.snapshots}->${newState.snapshots}, ` +
|
|
158
|
+
`files tracked ${Object.keys(newState.files).length}`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
const anyRows = n.limit_snapshots + n.sessions + n.token_events > 0;
|
|
162
|
+
// 沒有新事件時**仍然回報**(health-only 心跳)。舊版在這裡直接 return,於是
|
|
163
|
+
// 「機器閒置」與「採樣壞掉」在 server 眼中完全一樣。
|
|
164
|
+
if (!anyRows && !hasHealth) {
|
|
165
|
+
console.log("nothing to report (no new data, no health file)");
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
const dev = readJson(devicePath());
|
|
169
|
+
if (!dev?.device_key) {
|
|
170
|
+
console.error("device.json 不完整 — 請重跑 claude-usage enroll");
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
payload.device_id = dev.device_id;
|
|
174
|
+
const { status, body } = await post("/v1/report", payload, dev.device_key);
|
|
175
|
+
const kind = anyRows ? "report" : "heartbeat";
|
|
176
|
+
console.log(`${kind} -> ${status}: ${JSON.stringify(body)}`);
|
|
177
|
+
console.log(` sent: ${n.limit_snapshots} snapshots, ${n.sessions} sessions, ` +
|
|
178
|
+
`${n.token_events} token_events, health=${hasHealth ? "yes" : "no"}`);
|
|
179
|
+
if (status !== 200) {
|
|
180
|
+
console.error(" cursor NOT advanced — will retry the same batch next run");
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
// 只有這裡才前進游標。順序很重要:上傳成功 -> 存游標。
|
|
184
|
+
events.saveCursor(newState);
|
|
185
|
+
console.log(` cursor advanced (snapshots@${newState.snapshots}, ` +
|
|
186
|
+
`${Object.keys(newState.files).length} files tracked)`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
package/dist/usage.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 採一筆官方額度快照:GET /api/oauth/usage,寫進 ~/.claude-usage/usage_snapshots.jsonl。
|
|
3
|
+
*
|
|
4
|
+
* 這個數字是**帳號全域**的(天然含 claude.ai 網頁 chat),與 token_events 是兩個
|
|
5
|
+
* 獨立指標,不可相加。
|
|
6
|
+
*
|
|
7
|
+
* SECURITY:token 用於認證,**永不列印、永不寫入快照**。寫進檔案的只有數字。
|
|
8
|
+
*/
|
|
9
|
+
import { appendFileSync } from "node:fs";
|
|
10
|
+
import { CuError, EXIT, authHeaders, loadToken } from "./creds.js";
|
|
11
|
+
import { lastSnapshot } from "./health.js";
|
|
12
|
+
import { snapPath } from "./paths.js";
|
|
13
|
+
import { nowIso, parseIso } from "./util.js";
|
|
14
|
+
const URL_USAGE = "https://api.anthropic.com/api/oauth/usage";
|
|
15
|
+
/** hook 模式下,距上一筆快照未滿這麼多分鐘就跳過(開 session 不該每次都打 API) */
|
|
16
|
+
export const DEBOUNCE_MIN = 10;
|
|
17
|
+
/** hook 模式:距上一筆太近就跳過 */
|
|
18
|
+
export function debounced() {
|
|
19
|
+
const snap = lastSnapshot();
|
|
20
|
+
const t = parseIso(snap?.["collected_at"]);
|
|
21
|
+
if (!t)
|
|
22
|
+
return false;
|
|
23
|
+
return Date.now() - t.getTime() < DEBOUNCE_MIN * 60_000;
|
|
24
|
+
}
|
|
25
|
+
async function fetchUsage(token) {
|
|
26
|
+
let r;
|
|
27
|
+
try {
|
|
28
|
+
r = await fetch(URL_USAGE, {
|
|
29
|
+
method: "GET",
|
|
30
|
+
headers: { ...authHeaders(token), "User-Agent": "claude-usage/0.3" },
|
|
31
|
+
signal: AbortSignal.timeout(10_000),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new CuError(`network error: ${e.message}`, EXIT.NETWORK);
|
|
36
|
+
}
|
|
37
|
+
const headers = {};
|
|
38
|
+
r.headers.forEach((v, k) => {
|
|
39
|
+
headers[k] = v;
|
|
40
|
+
});
|
|
41
|
+
return { status: r.status, headers, body: await r.text() };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 採樣一次。回傳可區分的結束碼(daemon 靠它把失敗原因分開記錄):
|
|
45
|
+
* 0=ok 2=token_expired 3=no_token 4=http_error 5=network_error
|
|
46
|
+
*
|
|
47
|
+
* hook 模式(開 session 時觸發)**一律回 0 且安靜** —— 採集用量絕不該擋住或拖慢
|
|
48
|
+
* 使用者開 session。
|
|
49
|
+
*/
|
|
50
|
+
export async function sample(opts = {}) {
|
|
51
|
+
const hook = opts.hook === true;
|
|
52
|
+
const fail = (code, message) => ({
|
|
53
|
+
code: hook ? EXIT.OK : code,
|
|
54
|
+
status: null,
|
|
55
|
+
message,
|
|
56
|
+
});
|
|
57
|
+
if (hook && debounced())
|
|
58
|
+
return { code: EXIT.OK, status: null, message: "debounced" };
|
|
59
|
+
let tok;
|
|
60
|
+
try {
|
|
61
|
+
tok = loadToken();
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
const err = e;
|
|
65
|
+
return fail(err.code ?? EXIT.NO_TOKEN, err.message);
|
|
66
|
+
}
|
|
67
|
+
let res;
|
|
68
|
+
try {
|
|
69
|
+
res = await fetchUsage(tok.token);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
const err = e;
|
|
73
|
+
return fail(err.code ?? EXIT.NETWORK, err.message);
|
|
74
|
+
}
|
|
75
|
+
if (res.status !== 200) {
|
|
76
|
+
return {
|
|
77
|
+
...fail(EXIT.HTTP, `endpoint returned ${res.status}: ${res.body.slice(0, 200)}`),
|
|
78
|
+
status: res.status,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let data;
|
|
82
|
+
try {
|
|
83
|
+
data = JSON.parse(res.body);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return fail(EXIT.HTTP, "response was not JSON");
|
|
87
|
+
}
|
|
88
|
+
// 回應 headers 也帶即時的統一額度資訊,一併留存(不含任何機密)
|
|
89
|
+
const rl = {};
|
|
90
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
91
|
+
if (k.toLowerCase().startsWith("anthropic-ratelimit"))
|
|
92
|
+
rl[k] = v;
|
|
93
|
+
}
|
|
94
|
+
const snap = {
|
|
95
|
+
collected_at: nowIso(),
|
|
96
|
+
http_status: res.status,
|
|
97
|
+
subscription: tok.subscription, // 例 "team"
|
|
98
|
+
tier: tok.tier, // 例 "default_claude_max_5x" -- utilization% 的分母
|
|
99
|
+
cred_source: tok.source, // keychain | file -- 資料裡要看得出讀了哪個儲存
|
|
100
|
+
usage: data, // 官方數字,無機密
|
|
101
|
+
ratelimit_headers: rl,
|
|
102
|
+
};
|
|
103
|
+
appendFileSync(snapPath(), JSON.stringify(snap) + "\n", "utf8");
|
|
104
|
+
const u = (data ?? {});
|
|
105
|
+
const fh = (u["five_hour"] ?? {});
|
|
106
|
+
const sd = (u["seven_day"] ?? {});
|
|
107
|
+
return {
|
|
108
|
+
code: EXIT.OK,
|
|
109
|
+
status: res.status,
|
|
110
|
+
message: `5h=${fh["utilization"]}% week=${sd["utilization"]}% (creds_from=${tok.source})`,
|
|
111
|
+
};
|
|
112
|
+
}
|