@tsa-group/claude-usage 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/dist/creds.js +55 -13
- package/dist/install.js +18 -6
- package/dist/paths.js +12 -3
- package/package.json +17 -5
package/README.md
CHANGED
|
@@ -45,12 +45,20 @@ claude-usage configure --server https://<你們的 ingest host>
|
|
|
45
45
|
# 2) 一鍵安裝:enroll(取得身份)+ 註冊 SessionStart hook + 背景任務
|
|
46
46
|
# 先加 --dry-run 可預覽會做什麼、不動任何系統設定
|
|
47
47
|
claude-usage install --dry-run
|
|
48
|
-
claude-usage install
|
|
48
|
+
claude-usage install
|
|
49
49
|
|
|
50
50
|
# 3) 確認狀態
|
|
51
51
|
claude-usage status
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
**兩個可能讓你以為「裝壞了」的情況:**
|
|
55
|
+
|
|
56
|
+
- **server 開了人工核准** → `enroll` 會回 `status: pending`,你的資料要等管理者核准後才
|
|
57
|
+
開始進來。`status` 會顯示已 enroll、心跳正常,但 server 端看不到你的用量。這是正常的。
|
|
58
|
+
- **server 要求 enroll 密語**(多數部署不需要)→ `install --enroll-secret <密語>`,或用
|
|
59
|
+
環境變數 `CLAUDE_USAGE_ENROLL_SECRET`。⚠️ 寫在指令列會留在你的 shell history 裡,
|
|
60
|
+
能用環境變數就用環境變數。
|
|
61
|
+
|
|
54
62
|
安裝後會發生:
|
|
55
63
|
|
|
56
64
|
- **hook**(開 session 時):抓一筆即時額度快照。註冊在 `~/.claude/settings.json` 的
|
|
@@ -107,19 +115,20 @@ npm rm -g @tsa-group/claude-usage
|
|
|
107
115
|
| 指令 | 用途 |
|
|
108
116
|
|---|---|
|
|
109
117
|
| `configure --server <url>` | 設定 ingest server 位址 |
|
|
110
|
-
| `install [--
|
|
118
|
+
| `install [--dry-run]` | enroll + hook + 背景任務 |
|
|
111
119
|
| `status` | 裝好了嗎?**採樣真的有在動嗎?** |
|
|
112
120
|
| `uninstall` | 移除 hook 與背景任務 |
|
|
113
121
|
| `show [--history]` | 看自己的額度水位 / 時間序 |
|
|
114
122
|
| `sessions [--json]` | session 與 token 明細 |
|
|
115
123
|
| `sample [--hook]` | 手動抓一筆額度快照 |
|
|
116
124
|
| `daemon-tick [--explain]` | 背景排程器呼叫的單元;`--explain` 只印決策不採樣 |
|
|
117
|
-
| `enroll
|
|
125
|
+
| `enroll` | 手動與 server 溝通取得身份 |
|
|
118
126
|
| `report [--full] [--dry-run]` | 手動上報。`--full` 忽略游標整包重送(重送是安全的) |
|
|
119
127
|
| `health` | 背景任務心跳;不健康時 **exit 1**(可接監控) |
|
|
120
128
|
|
|
121
|
-
`--enroll-secret
|
|
122
|
-
|
|
129
|
+
`install` / `enroll` 都接受 `--enroll-secret <s>`,但**只有在 server 要求時才需要**。
|
|
130
|
+
它不會被寫進設定檔;若要避免留在 shell history,改用環境變數
|
|
131
|
+
`CLAUDE_USAGE_ENROLL_SECRET`。
|
|
123
132
|
|
|
124
133
|
---
|
|
125
134
|
|
|
@@ -199,8 +208,9 @@ Client 對 server 只用兩個端點,皆為 `application/json`:
|
|
|
199
208
|
- **Linux 背景任務未實作**(systemd --user timer)。其餘指令可用。
|
|
200
209
|
- **`session_id` 跨 compaction / resume 不穩定**:session **數**會高估。token 與成本不受影響
|
|
201
210
|
(那是 per-event 去重的,與 session 身份無關)。
|
|
202
|
-
- **enroll 是 client
|
|
203
|
-
|
|
211
|
+
- **enroll 是 client 自報身份**:profile 由 client 自己打 Anthropic 取得後轉送,理論上可
|
|
212
|
+
偽造 email / org。共享密語**不解決這件事**(知道密語的人一樣能冒充別人),它只擋路過的
|
|
213
|
+
流量。要真正的身份保證需要 server 端接 SSO / OIDC,或用人工核准流程把關。
|
|
204
214
|
|
|
205
215
|
---
|
|
206
216
|
|
package/dist/creds.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
13
|
import { userInfo } from "node:os";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { claudeDir, credsPath } from "./paths.js";
|
|
16
16
|
/** 可區分的結束碼 —— daemon 靠這個把「token 過期」和「網路壞掉」分開記錄。
|
|
17
17
|
* 過去兩者都是 exit 1,daemon 只能寫 sample-failed,於是 2026-08-22 那次
|
|
18
18
|
* token 過期靜默死了 17.6 小時沒人發現。 */
|
|
@@ -24,11 +24,15 @@ export const EXIT = {
|
|
|
24
24
|
NETWORK: 5,
|
|
25
25
|
};
|
|
26
26
|
export class CuError extends Error {
|
|
27
|
+
// ★ 刻意不用 constructor parameter property(`constructor(msg, readonly code)`):
|
|
28
|
+
// tsc 吃得下,但 Node 的 strip-only 型別剝除**不支援**,於是 `node --test` 永遠
|
|
29
|
+
// 載不進這個模組 —— 這正是憑證這條路徑一路裸奔到第一台 Windows 機器才爆掉的原因。
|
|
30
|
+
// 出貨的是 dist/(tsc 編過),所以問題不會在生產顯現,只會讓它「無法被測試」。
|
|
27
31
|
code;
|
|
28
32
|
constructor(message, code) {
|
|
29
33
|
super(message);
|
|
30
|
-
this.code = code;
|
|
31
34
|
this.name = "CuError";
|
|
35
|
+
this.code = code;
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
@@ -57,11 +61,40 @@ function fromKeychain() {
|
|
|
57
61
|
return [null, "keychain-not-json"];
|
|
58
62
|
}
|
|
59
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* 讀檔案版憑證。**每種失敗都有自己的名字。**
|
|
66
|
+
*
|
|
67
|
+
* 舊版一律回 `file-error:unreadable`,把「檔案不存在」「沒權限」「JSON 壞掉」
|
|
68
|
+
* 「有檔但沒 token」混成同一句話 —— 而這四種的處置完全不同(跑 /login vs 修權限
|
|
69
|
+
* vs 檔案損毀 vs 用的是 API key 而非 OAuth)。第一台 Windows 機器卡住時,那句話
|
|
70
|
+
* 讓人完全看不出該做什麼。
|
|
71
|
+
*/
|
|
60
72
|
function fromFile() {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
const path = credsPath();
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = readFileSync(path, "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
const code = e.code;
|
|
80
|
+
if (code === "ENOENT")
|
|
81
|
+
return [null, "file-not-found"];
|
|
82
|
+
if (code === "EACCES" || code === "EPERM")
|
|
83
|
+
return [null, "file-no-permission"];
|
|
84
|
+
return [null, `file-error:${code ?? "unknown"}`];
|
|
85
|
+
}
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(raw);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [null, "file-bad-json"];
|
|
92
|
+
}
|
|
93
|
+
if (!parsed?.claudeAiOauth)
|
|
94
|
+
return [null, "file-no-oauth-key"];
|
|
95
|
+
if (!parsed.claudeAiOauth.accessToken)
|
|
96
|
+
return [null, "file-no-token"];
|
|
97
|
+
return [parsed.claudeAiOauth, "file"];
|
|
65
98
|
}
|
|
66
99
|
/**
|
|
67
100
|
* 讀 access token。**刻意不做 refresh**:refresh token 若是一次性輪替,我們換完之後
|
|
@@ -74,12 +107,21 @@ export function loadToken() {
|
|
|
74
107
|
const kcWhy = source;
|
|
75
108
|
[o, source] = fromFile();
|
|
76
109
|
if (!o?.accessToken) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
110
|
+
const hints = {
|
|
111
|
+
"keychain-locked": "解鎖 login keychain(不是重新登入)",
|
|
112
|
+
"keychain-no-item": "在 Claude Code 裡跑 /login",
|
|
113
|
+
"file-not-found": `找不到 ${credsPath()} —— 在 Claude Code 裡跑 /login;` +
|
|
114
|
+
`若你用的是 API key / Bedrock / Vertex 而非帳號登入,本工具無法取得額度資訊`,
|
|
115
|
+
"file-no-permission": `沒有權限讀 ${credsPath()}`,
|
|
116
|
+
"file-bad-json": `${credsPath()} 不是合法 JSON(檔案損毀?)`,
|
|
117
|
+
"file-no-oauth-key": `${credsPath()} 裡沒有 claudeAiOauth —— 多半是用 API key 登入的`,
|
|
118
|
+
"file-no-token": `${credsPath()} 裡沒有 accessToken —— 在 Claude Code 裡跑 /login`,
|
|
119
|
+
};
|
|
120
|
+
const hint = hints[source] ?? hints[kcWhy] ?? "";
|
|
121
|
+
throw new CuError(`cannot read credentials (keychain: ${kcWhy}; file: ${source})` +
|
|
122
|
+
(hint ? `\n -> ${hint}` : "") +
|
|
123
|
+
`\n 設定目錄: ${claudeDir()}` +
|
|
124
|
+
(process.env["CLAUDE_CONFIG_DIR"] ? " (來自 CLAUDE_CONFIG_DIR)" : ""), EXIT.NO_TOKEN);
|
|
83
125
|
}
|
|
84
126
|
}
|
|
85
127
|
if (o.expiresAt && Date.now() >= o.expiresAt) {
|
package/dist/install.js
CHANGED
|
@@ -13,7 +13,7 @@ import { dirname, join } from "node:path";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { claudeSettingsPath, devicePath, logPath, serverUrl, stateDir } from "./paths.js";
|
|
15
15
|
import { printHealth } from "./daemon.js";
|
|
16
|
-
import { enroll
|
|
16
|
+
import { enroll } from "./upload.js";
|
|
17
17
|
import { readJson, writeJsonAtomic } from "./util.js";
|
|
18
18
|
const LABEL = "com.tsa.claude-usage";
|
|
19
19
|
const WIN_TASK = "ClaudeUsage";
|
|
@@ -104,11 +104,22 @@ function winInstall() {
|
|
|
104
104
|
"/TR", tr,
|
|
105
105
|
"/RL", "LIMITED", "/F",
|
|
106
106
|
];
|
|
107
|
-
|
|
107
|
+
// 不設 encoding:Windows 主控台是本地碼頁(繁中是 CP950),用 utf8 解碼會變亂碼,
|
|
108
|
+
// 而亂碼會把「真正的錯誤訊息」也一起藏掉。rc 才是可靠的訊號。
|
|
109
|
+
const r = spawnSync("schtasks", args);
|
|
108
110
|
console.log(`wrote ${VBS_PATH()}`);
|
|
109
|
-
|
|
111
|
+
if (r.status === 0) {
|
|
112
|
+
console.log(`schtasks: 已建立工作 "${WIN_TASK}",每 ${Math.max(1, Math.floor(BASE_INTERVAL_SEC / 60))} 分鐘執行一次`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// 失敗才印原始輸出。用 latin1 至少保證位元組不失真,並告訴使用者怎麼看到真訊息。
|
|
116
|
+
const raw = Buffer.concat([r.stdout ?? Buffer.alloc(0), r.stderr ?? Buffer.alloc(0)])
|
|
117
|
+
.toString("latin1").trim();
|
|
118
|
+
console.error(`schtasks 失敗 rc=${r.status}${raw ? ` (原始輸出可能因碼頁而亂碼: ${raw})` : ""}`);
|
|
119
|
+
console.error(` 手動執行這行可看到正確訊息: schtasks ${args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ")}`);
|
|
120
|
+
}
|
|
110
121
|
console.log(`uninstall: schtasks /Delete /TN ${WIN_TASK} /F`);
|
|
111
|
-
console.log("! Windows
|
|
122
|
+
console.log("! Windows 路徑尚未在真機完整驗證 —— 裝完請跑 claude-usage status 確認有心跳");
|
|
112
123
|
return r.status ?? 1;
|
|
113
124
|
}
|
|
114
125
|
function winUninstall() {
|
|
@@ -216,8 +227,9 @@ export async function install(argv = []) {
|
|
|
216
227
|
const os = platform();
|
|
217
228
|
console.log(`claude-usage install (${os})${isDry(argv) ? " [DRY-RUN]" : ""}`);
|
|
218
229
|
console.log(` server = ${serverUrl()} state = ${stateDir()}`);
|
|
219
|
-
if (!
|
|
220
|
-
|
|
230
|
+
if (!existsSync(devicePath())) {
|
|
231
|
+
// 註冊需要管理者核准,同事看到「裝好了但沒資料」時第一個要想到的就是這件事。
|
|
232
|
+
console.log(" ! 首次註冊後需管理者核准,核准前你的資料不會進來(這是正常的)");
|
|
221
233
|
}
|
|
222
234
|
await enrollStep(argv);
|
|
223
235
|
registerHook(argv);
|
package/dist/paths.js
CHANGED
|
@@ -33,12 +33,21 @@ export const healthPath = () => join(stateDir(), "health.json");
|
|
|
33
33
|
export const cursorPath = () => join(stateDir(), "cursor.json");
|
|
34
34
|
/** daemon 的 stdout/stderr(背景任務寫的) */
|
|
35
35
|
export const logPath = () => join(stateDir(), "daemon.log");
|
|
36
|
+
/**
|
|
37
|
+
* Claude Code 的設定目錄。
|
|
38
|
+
*
|
|
39
|
+
* ★ 必須看 `CLAUDE_CONFIG_DIR`:那是 Claude Code 官方支援的搬家方式(Linux/Windows),
|
|
40
|
+
* 設了之後 `.credentials.json`、`projects/`、`settings.json` 全都在那底下。寫死
|
|
41
|
+
* `~/.claude` 的後果是「憑證讀不到」而且**錯誤訊息會指向一個根本不該去看的路徑** ——
|
|
42
|
+
* 2026-08-25 第一台 Windows 機器就是卡在憑證讀取,這是必須先排除的可能。
|
|
43
|
+
*/
|
|
44
|
+
export const claudeDir = () => process.env["CLAUDE_CONFIG_DIR"] || join(homedir(), ".claude");
|
|
36
45
|
/** Claude Code 的 OAuth 憑證檔。**唯讀、永不上傳**(且在 mac 上只是過時副本,見 creds.ts) */
|
|
37
|
-
export const credsPath = () => join(
|
|
46
|
+
export const credsPath = () => join(claudeDir(), ".credentials.json");
|
|
38
47
|
/** Claude Code 的 session JSONL 根目錄。唯讀 */
|
|
39
|
-
export const projectsDir = () => join(
|
|
48
|
+
export const projectsDir = () => join(claudeDir(), "projects");
|
|
40
49
|
/** Claude Code 設定(SessionStart hook 註冊在這) */
|
|
41
|
-
export const claudeSettingsPath = () => join(
|
|
50
|
+
export const claudeSettingsPath = () => join(claudeDir(), "settings.json");
|
|
42
51
|
export const DEFAULT_SERVER = "http://127.0.0.1:8787";
|
|
43
52
|
export function config() {
|
|
44
53
|
return readJson(configPath()) ?? {};
|
package/package.json
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsa-group/claude-usage",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
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
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-usage": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
9
16
|
"scripts": {
|
|
10
17
|
"build": "tsc -p tsconfig.json",
|
|
11
18
|
"test": "node --test test/*.test.ts",
|
|
12
19
|
"prepublishOnly": "npm run build && npm test",
|
|
13
20
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
14
21
|
},
|
|
15
|
-
"keywords": [
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude",
|
|
24
|
+
"usage",
|
|
25
|
+
"telemetry",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
16
28
|
"license": "UNLICENSED",
|
|
17
29
|
"private": false,
|
|
18
30
|
"devDependencies": {
|