@tsa-group/claude-usage 0.3.4 → 0.4.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 +22 -3
- package/dist/creds-win.js +249 -0
- package/dist/creds.js +56 -28
- package/dist/daemon.js +22 -4
- package/dist/doctor.js +28 -1
- package/dist/install.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,19 @@ ingest server,彙整成團隊用量儀表。
|
|
|
22
22
|
|
|
23
23
|
原始碼很短,`src/events.ts` 就是「哪些欄位會被送出去」的唯一權威來源,可以自己讀過再裝。
|
|
24
24
|
|
|
25
|
+
**它會怎麼取得你的 token**(只用於認證,永不上傳):
|
|
26
|
+
|
|
27
|
+
| 平台 | 來源 |
|
|
28
|
+
|---|---|
|
|
29
|
+
| macOS | Keychain(`Claude Code-credentials`),檔案版只是可能過時的副本 |
|
|
30
|
+
| Windows / Linux · CLI | `%USERPROFILE%\.claude\.credentials.json`(**純文字**,Claude Code 自己就是這樣存的) |
|
|
31
|
+
| Windows · 桌面版 | 桌面版的加密憑證庫 —— 用 DPAPI 解出金鑰後以 AES-256-GCM 解密 |
|
|
32
|
+
|
|
33
|
+
最後一項值得你知道再決定要不要裝:**只用桌面版、沒登入過終端機 `claude` 的人,
|
|
34
|
+
除此之外沒有任何取得 token 的方式**,而沒有 token 就沒有額度資料。解密只在你自己的
|
|
35
|
+
機器、你自己的 Windows 帳號下進行(DPAPI 的設計就綁這兩件事),解出的內容只留在
|
|
36
|
+
記憶體。不想要這條路徑就登入一次終端機的 `claude`,工具會優先用那份純文字憑證。
|
|
37
|
+
|
|
25
38
|
---
|
|
26
39
|
|
|
27
40
|
## 需求
|
|
@@ -29,7 +42,7 @@ ingest server,彙整成團隊用量儀表。
|
|
|
29
42
|
- **Node.js >= 20**
|
|
30
43
|
- **Claude Code** 已登入(工具讀它的憑證來認證,見上方隱私說明)
|
|
31
44
|
- macOS:完整支援並實測
|
|
32
|
-
- Windows
|
|
45
|
+
- Windows:CLI 與**桌面版(Microsoft Store / MSIX)**都支援;桌面版憑證路徑見「已知限制」
|
|
33
46
|
- Linux:背景任務(systemd timer)未實作,其餘指令可用
|
|
34
47
|
|
|
35
48
|
---
|
|
@@ -202,9 +215,15 @@ Client 對 server 只用兩個端點,皆為 `application/json`:
|
|
|
202
215
|
## 已知限制
|
|
203
216
|
|
|
204
217
|
- **非官方 endpoint**:額度 % 來自 Claude Code 內部的 `/api/oauth/usage`,Anthropic 可能變更。
|
|
205
|
-
- **Windows
|
|
206
|
-
|
|
218
|
+
- **Windows 桌面版憑證是逆向出來的**:桌面版把 OAuth token 存在
|
|
219
|
+
`%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\config.json` 的
|
|
220
|
+
`oauth:tokenCacheV2`,以 Chromium 的 `v10` 方案(DPAPI + AES-256-GCM)加密。
|
|
221
|
+
這是**未公開的內部格式**,Anthropic 改版就可能失效 —— 與 `/api/oauth/usage` 同一個
|
|
222
|
+
風險類別。失效時會在 `claude-usage doctor` 顯示卡在哪一步,不會靜默。
|
|
223
|
+
- **Windows 背景任務未完整實機驗證**:schtasks + VBS 隱藏視窗啟動器。裝完請用
|
|
207
224
|
`claude-usage status` 確認有心跳。
|
|
225
|
+
- **PowerShell 執行原則**:若整台停用指令碼執行,`npm i -g` 會失敗(載不進 `npm.ps1`)。
|
|
226
|
+
用 `npm.cmd i -g …`,或 `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`(免管理員)。
|
|
208
227
|
- **Linux 背景任務未實作**(systemd --user timer)。其餘指令可用。
|
|
209
228
|
- **`session_id` 跨 compaction / resume 不穩定**:session **數**會高估。token 與成本不受影響
|
|
210
229
|
(那是 per-event 去重的,與 session 身份無關)。
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 桌面版(Microsoft Store / MSIX)的 Claude OAuth 憑證讀取。
|
|
3
|
+
*
|
|
4
|
+
* 為什麼需要這條路徑:**沒登入過 CLI 的人,`~/.claude/.credentials.json` 裡根本沒有
|
|
5
|
+
* `claudeAiOauth`**。第一台實機(2026-08-27)就是這樣——工具報「多半是用 API key
|
|
6
|
+
* 登入的」,但她其實是正常的訂閱制使用者,只是用桌面版。
|
|
7
|
+
*
|
|
8
|
+
* 三件事讓這個憑證庫「看起來不存在」,全碟搜尋也找不到:
|
|
9
|
+
* 1. **MSIX 路徑重導向**:App 命令列寫的是 `--user-data-dir=%APPDATA%\Claude`,但
|
|
10
|
+
* MSIX 封裝會把它重導到 `%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude`。
|
|
11
|
+
* 直接看 `%APPDATA%\Claude` 會說不存在。
|
|
12
|
+
* 2. **鍵名不同**:桌面版用 `oauth:tokenCacheV2`,不是 CLI 的 `claudeAiOauth`。
|
|
13
|
+
* 3. **值是密文**:base64 包住的 Chromium `v10` 區塊,所以搜明文鍵名永遠搜不到。
|
|
14
|
+
*
|
|
15
|
+
* 解出來的 token 與 CLI 的**完全同型**(`sk-ant-oat01-…`),打同一支
|
|
16
|
+
* `/api/oauth/usage`,所以下游一律不必分辨來源。
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ 這是未公開的內部儲存格式,Anthropic 改版就可能失效——與 `/api/oauth/usage`
|
|
19
|
+
* 同一個風險類別。失敗一律回可辨識的 source 字串,不要拋例外把 daemon 打掛。
|
|
20
|
+
*
|
|
21
|
+
* SECURITY:解出的 master key 與 token 只留在記憶體,**永不列印、永不寫檔、永不上傳**。
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { createDecipheriv } from "node:crypto";
|
|
25
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
/** Chromium 加密區塊的前綴。看到別的就是格式變了,不要硬解。 */
|
|
28
|
+
const V10 = "v10";
|
|
29
|
+
/** DPAPI 密文在 `os_crypt.encrypted_key` 裡的前綴,要先剝掉 */
|
|
30
|
+
const DPAPI_PREFIX = "DPAPI";
|
|
31
|
+
/**
|
|
32
|
+
* 桌面版資料目錄的候選清單,依可能性排序。
|
|
33
|
+
*
|
|
34
|
+
* `Claude_pzs8sxrjxfjjc` 那串是 Store 封裝的發行者雜湊。**刻意不寫死** —— 它理論上
|
|
35
|
+
* 固定,但賭這件事沒有好處,用 glob 找 `Claude_*` 成本一樣。
|
|
36
|
+
*/
|
|
37
|
+
export function desktopDataDirs() {
|
|
38
|
+
const out = [];
|
|
39
|
+
const local = process.env["LOCALAPPDATA"];
|
|
40
|
+
if (local) {
|
|
41
|
+
const packages = join(local, "Packages");
|
|
42
|
+
try {
|
|
43
|
+
for (const name of readdirSync(packages)) {
|
|
44
|
+
if (!name.startsWith("Claude_"))
|
|
45
|
+
continue;
|
|
46
|
+
out.push({
|
|
47
|
+
dir: join(packages, name, "LocalCache", "Roaming", "Claude"),
|
|
48
|
+
kind: "desktop-msix",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// 沒有 Packages 目錄是正常的(非 Store 安裝)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// 非 MSIX(直接下載的安裝檔)會用一般的 %APPDATA%\Claude
|
|
57
|
+
const roaming = process.env["APPDATA"];
|
|
58
|
+
if (roaming)
|
|
59
|
+
out.push({ dir: join(roaming, "Claude"), kind: "desktop-appdata" });
|
|
60
|
+
return out.filter((c) => existsSync(join(c.dir, "config.json")));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 用 DPAPI 解出 Chromium 的 master key。
|
|
64
|
+
*
|
|
65
|
+
* Node 沒有 `CryptUnprotectData`,而我們是零執行期依賴 —— 裝 native addon 會同時
|
|
66
|
+
* 打破零依賴與免編譯兩個前提。改成 spawn PowerShell 呼叫 .NET,**與 mac 上
|
|
67
|
+
* spawn `security` 讀 Keychain 是同一個做法**,架構對稱。
|
|
68
|
+
*
|
|
69
|
+
* ★ 只讓 PowerShell 做 DPAPI 這一步,AES-GCM 交給 Node 的 crypto。這樣才避開
|
|
70
|
+
* 「AES-GCM 需要 PowerShell 7、否則要用 CNG P/Invoke」那個限制 —— 那個限制只在
|
|
71
|
+
* 整段都用 PowerShell 做時才存在,而實機上很可能只有 Windows PowerShell 5.1。
|
|
72
|
+
*
|
|
73
|
+
* 密文走 stdin 不走 argv:argv 會出現在行程清單裡。
|
|
74
|
+
*/
|
|
75
|
+
function dpapiUnprotect(encryptedB64) {
|
|
76
|
+
const script = [
|
|
77
|
+
"$ErrorActionPreference='Stop'",
|
|
78
|
+
"Add-Type -AssemblyName System.Security",
|
|
79
|
+
"$b64 = [Console]::In.ReadToEnd().Trim()",
|
|
80
|
+
"$enc = [Convert]::FromBase64String($b64)",
|
|
81
|
+
"$dec = [System.Security.Cryptography.ProtectedData]::Unprotect(" +
|
|
82
|
+
"$enc, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)",
|
|
83
|
+
"[Convert]::ToBase64String($dec)",
|
|
84
|
+
].join("; ");
|
|
85
|
+
// 5.1 一定在;pwsh 當備援。-ExecutionPolicy Bypass 是必要的:實機遇過整台
|
|
86
|
+
// 停用指令碼執行(npm.ps1 都載不進去),而 -Command 帶的字串仍受原則約束。
|
|
87
|
+
for (const exe of ["powershell.exe", "pwsh"]) {
|
|
88
|
+
let r;
|
|
89
|
+
try {
|
|
90
|
+
r = spawnSync(exe, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { input: encryptedB64, encoding: "utf8", timeout: 20_000, windowsHide: true });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue; // 這個直譯器不存在,換下一個
|
|
94
|
+
}
|
|
95
|
+
if (r.error || r.status !== 0)
|
|
96
|
+
continue;
|
|
97
|
+
const out = (r.stdout ?? "").trim();
|
|
98
|
+
if (!out)
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
const key = Buffer.from(out, "base64");
|
|
102
|
+
// AES-256 的金鑰必須是 32 byte。長度不對代表解出來的不是我們以為的東西。
|
|
103
|
+
if (key.length !== 32)
|
|
104
|
+
return [null, `dpapi-bad-key-len:${key.length}`];
|
|
105
|
+
return [key, "ok"];
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [null, "dpapi-bad-base64"];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [null, "dpapi-failed"];
|
|
112
|
+
}
|
|
113
|
+
/** 讀 `Local State` 的 os_crypt.encrypted_key,DPAPI 解成 32-byte AES 金鑰 */
|
|
114
|
+
function masterKey(dir) {
|
|
115
|
+
const p = join(dir, "Local State");
|
|
116
|
+
let raw;
|
|
117
|
+
try {
|
|
118
|
+
raw = readFileSync(p, "utf8");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return [null, "no-local-state"];
|
|
122
|
+
}
|
|
123
|
+
let encB64;
|
|
124
|
+
try {
|
|
125
|
+
encB64 = JSON.parse(raw)?.os_crypt
|
|
126
|
+
?.encrypted_key;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [null, "local-state-bad-json"];
|
|
130
|
+
}
|
|
131
|
+
if (typeof encB64 !== "string" || !encB64)
|
|
132
|
+
return [null, "no-encrypted-key"];
|
|
133
|
+
let blob;
|
|
134
|
+
try {
|
|
135
|
+
blob = Buffer.from(encB64, "base64");
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [null, "encrypted-key-bad-base64"];
|
|
139
|
+
}
|
|
140
|
+
if (blob.subarray(0, DPAPI_PREFIX.length).toString("latin1") !== DPAPI_PREFIX) {
|
|
141
|
+
return [null, "encrypted-key-no-dpapi-prefix"];
|
|
142
|
+
}
|
|
143
|
+
return dpapiUnprotect(blob.subarray(DPAPI_PREFIX.length).toString("base64"));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 拆 Chromium 的 v10 區塊並用 AES-256-GCM 解密。
|
|
147
|
+
* 結構:`"v10"`(3B) │ nonce(12B) │ ciphertext(…) │ GCM tag(16B)
|
|
148
|
+
*/
|
|
149
|
+
export function decryptV10(b64, key) {
|
|
150
|
+
let blob;
|
|
151
|
+
try {
|
|
152
|
+
blob = Buffer.from(b64, "base64");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return [null, "cache-bad-base64"];
|
|
156
|
+
}
|
|
157
|
+
if (blob.subarray(0, 3).toString("latin1") !== V10) {
|
|
158
|
+
return [null, `cache-not-v10:${blob.subarray(0, 3).toString("latin1")}`];
|
|
159
|
+
}
|
|
160
|
+
// 3 + 12 + 16 = 31 是「空密文」的最小長度,比這短就是壞掉的
|
|
161
|
+
if (blob.length < 32)
|
|
162
|
+
return [null, "cache-too-short"];
|
|
163
|
+
const nonce = blob.subarray(3, 15);
|
|
164
|
+
const tag = blob.subarray(blob.length - 16);
|
|
165
|
+
const ct = blob.subarray(15, blob.length - 16);
|
|
166
|
+
try {
|
|
167
|
+
const d = createDecipheriv("aes-256-gcm", key, nonce);
|
|
168
|
+
d.setAuthTag(tag);
|
|
169
|
+
return [Buffer.concat([d.update(ct), d.final()]).toString("utf8"), "ok"];
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// GCM tag 驗證失敗 = 金鑰不對或資料被改過。不要當成「格式變了」。
|
|
173
|
+
return [null, "cache-decrypt-failed"];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 從解密後的 token cache 挑出可用的那一筆。
|
|
178
|
+
*
|
|
179
|
+
* key 的形狀是 `clientId:orgUUID:audience:scopes`,一個 cache 裡會有多筆(不同
|
|
180
|
+
* client / scope)。要的是含 **`user:inference`** 的那筆 —— 那是 Claude Code 用來
|
|
181
|
+
* 推論的 token,也是唯一打得動 `/api/oauth/usage` 的。
|
|
182
|
+
*/
|
|
183
|
+
export function pickEntry(plain) {
|
|
184
|
+
let obj;
|
|
185
|
+
try {
|
|
186
|
+
obj = JSON.parse(plain);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return [null, "cache-plain-bad-json"];
|
|
190
|
+
}
|
|
191
|
+
const hit = Object.entries(obj).find(([k, v]) => k.includes("user:inference") && typeof v?.token === "string" && v.token);
|
|
192
|
+
if (!hit)
|
|
193
|
+
return [null, "cache-no-inference-entry"];
|
|
194
|
+
const e = hit[1];
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
accessToken: e.token,
|
|
198
|
+
expiresAt: e.expiresAt,
|
|
199
|
+
subscriptionType: e.subscriptionType,
|
|
200
|
+
rateLimitTier: e.rateLimitTier,
|
|
201
|
+
},
|
|
202
|
+
"ok",
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Windows 桌面版憑證。回 `[creds, source]`;失敗時 creds 為 null,而 source 是
|
|
207
|
+
* **可辨識的失敗原因**(會被記進 health / cred_source,之後從資料就看得出卡在哪一步)。
|
|
208
|
+
*/
|
|
209
|
+
export function fromWindowsDesktop() {
|
|
210
|
+
if (process.platform !== "win32")
|
|
211
|
+
return [null, "not-windows"];
|
|
212
|
+
const dirs = desktopDataDirs();
|
|
213
|
+
if (!dirs.length)
|
|
214
|
+
return [null, "desktop-no-datadir"];
|
|
215
|
+
let lastWhy = "desktop-no-datadir";
|
|
216
|
+
for (const { dir, kind } of dirs) {
|
|
217
|
+
const [key, keyWhy] = masterKey(dir);
|
|
218
|
+
if (!key) {
|
|
219
|
+
lastWhy = `desktop-${keyWhy}`;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
let cfg;
|
|
223
|
+
try {
|
|
224
|
+
cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
lastWhy = "desktop-config-unreadable";
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// V2 是新版;舊版鍵名沒有 V2 後綴。兩個都試,先新後舊。
|
|
231
|
+
const cached = cfg["oauth:tokenCacheV2"] ?? cfg["oauth:tokenCache"];
|
|
232
|
+
if (typeof cached !== "string" || !cached) {
|
|
233
|
+
lastWhy = "desktop-no-token-cache";
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const [plain, decWhy] = decryptV10(cached, key);
|
|
237
|
+
if (!plain) {
|
|
238
|
+
lastWhy = `desktop-${decWhy}`;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const [creds, pickWhy] = pickEntry(plain);
|
|
242
|
+
if (!creds) {
|
|
243
|
+
lastWhy = `desktop-${pickWhy}`;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
return [creds, kind];
|
|
247
|
+
}
|
|
248
|
+
return [null, lastWhy];
|
|
249
|
+
}
|
package/dist/creds.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawnSync } from "node:child_process";
|
|
|
13
13
|
import { userInfo } from "node:os";
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
15
|
import { claudeDir, credsPath } from "./paths.js";
|
|
16
|
+
import { fromWindowsDesktop } from "./creds-win.js";
|
|
16
17
|
/** 可區分的結束碼 —— daemon 靠這個把「token 過期」和「網路壞掉」分開記錄。
|
|
17
18
|
* 過去兩者都是 exit 1,daemon 只能寫 sample-failed,於是 2026-08-22 那次
|
|
18
19
|
* token 過期靜默死了 17.6 小時沒人發現。 */
|
|
@@ -101,40 +102,67 @@ function fromFile() {
|
|
|
101
102
|
* Claude Code 拿舊的去用會失敗,可能把使用者登出。這個風險不值得為了背景採樣承擔
|
|
102
103
|
* —— 過期就大聲回報,讓使用者自己 /login。
|
|
103
104
|
*/
|
|
105
|
+
const expired = (o) => Boolean(o.expiresAt && Date.now() >= o.expiresAt);
|
|
106
|
+
/**
|
|
107
|
+
* 憑證來源,依「先便宜、先可能命中」排序,且**惰性求值** —— 前面的來源拿到堪用的
|
|
108
|
+
* token 就不會往下試。順序有成本意義:`fromWindowsDesktop` 會 spawn PowerShell
|
|
109
|
+
* (數百毫秒),不該在 CLI 檔案好好的機器上每次採樣都跑一次。
|
|
110
|
+
*/
|
|
111
|
+
const SOURCES = [
|
|
112
|
+
fromKeychain, // macOS 主儲存
|
|
113
|
+
fromFile, // CLI 的檔案(Windows/Linux 是主儲存;mac 上只是過時副本)
|
|
114
|
+
fromWindowsDesktop, // Windows 桌面版(MSIX,加密)
|
|
115
|
+
];
|
|
104
116
|
export function loadToken() {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
[o, source] =
|
|
117
|
+
const why = [];
|
|
118
|
+
let staleFound = null;
|
|
119
|
+
for (const get of SOURCES) {
|
|
120
|
+
const [o, source] = get();
|
|
109
121
|
if (!o?.accessToken) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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);
|
|
122
|
+
why.push(source);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// ★ 過期不代表要放棄:CLI 的檔可能是舊的,而桌面版是新的(或反過來)。
|
|
126
|
+
// 記下來繼續往下找,全部都過期了才報 TOKEN_EXPIRED。
|
|
127
|
+
if (expired(o)) {
|
|
128
|
+
staleFound ??= { creds: o, source };
|
|
129
|
+
why.push(`${source}(expired)`);
|
|
130
|
+
continue;
|
|
125
131
|
}
|
|
132
|
+
return {
|
|
133
|
+
token: o.accessToken,
|
|
134
|
+
subscription: o.subscriptionType ?? null,
|
|
135
|
+
tier: o.rateLimitTier ?? null,
|
|
136
|
+
source,
|
|
137
|
+
};
|
|
126
138
|
}
|
|
127
|
-
if (
|
|
128
|
-
const hrs = (Date.now() -
|
|
129
|
-
throw new CuError(`access token EXPIRED ${hrs.toFixed(1)}h ago —
|
|
130
|
-
|
|
139
|
+
if (staleFound) {
|
|
140
|
+
const hrs = (Date.now() - staleFound.creds.expiresAt) / 3_600_000;
|
|
141
|
+
throw new CuError(`access token EXPIRED ${hrs.toFixed(1)}h ago (${staleFound.source}) — ` +
|
|
142
|
+
`在 Claude Code 或桌面版重新登入即可換發`, EXIT.TOKEN_EXPIRED);
|
|
131
143
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
const hints = {
|
|
145
|
+
"keychain-locked": "解鎖 login keychain(不是重新登入)",
|
|
146
|
+
"keychain-no-item": "在 Claude Code 裡跑 /login",
|
|
147
|
+
"file-not-found": `找不到 ${credsPath()} —— 在 Claude Code 裡跑 /login;` +
|
|
148
|
+
`若你用的是 API key / Bedrock / Vertex 而非帳號登入,本工具無法取得額度資訊`,
|
|
149
|
+
"file-no-permission": `沒有權限讀 ${credsPath()}`,
|
|
150
|
+
"file-bad-json": `${credsPath()} 不是合法 JSON(檔案損毀?)`,
|
|
151
|
+
// ⚠️ 這句以前寫「多半是用 API key 登入的」,實機證明那個推測是錯的:
|
|
152
|
+
// 第一台 Windows 是正常訂閱制,只是用桌面版而沒登入過 CLI。
|
|
153
|
+
"file-no-oauth-key": `${credsPath()} 只有 CLI 的資料、沒有 claudeAiOauth —— ` +
|
|
154
|
+
`你可能只用桌面版而沒登入過終端機的 claude`,
|
|
155
|
+
"file-no-token": `${credsPath()} 裡沒有 accessToken —— 在 Claude Code 裡跑 /login`,
|
|
156
|
+
"desktop-no-datadir": "找不到桌面版資料目錄(沒裝桌面版,或不是 Store 版)",
|
|
157
|
+
"desktop-dpapi-failed": "DPAPI 解密失敗 —— 必須在裝了桌面版的那台、用同一個 Windows 帳號執行",
|
|
158
|
+
"desktop-no-token-cache": "桌面版設定裡沒有 oauth:tokenCache —— 桌面版可能沒登入",
|
|
159
|
+
"desktop-cache-decrypt-failed": "桌面版憑證解密失敗(金鑰不符或格式已變更)",
|
|
137
160
|
};
|
|
161
|
+
const hint = why.map((w) => hints[w]).find(Boolean) ?? "";
|
|
162
|
+
throw new CuError(`cannot read credentials (${why.join("; ")})` +
|
|
163
|
+
(hint ? `\n -> ${hint}` : "") +
|
|
164
|
+
`\n 設定目錄: ${claudeDir()}` +
|
|
165
|
+
(process.env["CLAUDE_CONFIG_DIR"] ? " (來自 CLAUDE_CONFIG_DIR)" : ""), EXIT.NO_TOKEN);
|
|
138
166
|
}
|
|
139
167
|
/** 打 Anthropic OAuth API 用的共同 headers。token 只出現在這裡。 */
|
|
140
168
|
export const authHeaders = (token) => ({
|
package/dist/daemon.js
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
import { statSync, readdirSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
|
-
import { EXIT } from "./creds.js";
|
|
11
|
+
import { EXIT, loadToken } from "./creds.js";
|
|
12
12
|
import { healthWarning, lastSnapshot, loadHealth, saveHealth, } from "./health.js";
|
|
13
13
|
import { devicePath, projectsDir } from "./paths.js";
|
|
14
|
-
import { report } from "./upload.js";
|
|
14
|
+
import { enroll, report } from "./upload.js";
|
|
15
15
|
import { sample } from "./usage.js";
|
|
16
16
|
import { nowIso, parseIso } from "./util.js";
|
|
17
17
|
// ── 自適應策略 ──
|
|
@@ -137,8 +137,26 @@ export function decide() {
|
|
|
137
137
|
* 都只記錄不拋出 —— daemon 絕不能因為上傳失敗就整個死掉。
|
|
138
138
|
*/
|
|
139
139
|
async function flushUploads() {
|
|
140
|
-
if (!existsSync(devicePath()))
|
|
141
|
-
|
|
140
|
+
if (!existsSync(devicePath())) {
|
|
141
|
+
// install 當下 enroll 失敗(幾乎都是憑證還沒好)時,由這裡負責補上。
|
|
142
|
+
// 否則 install 印的「下次會自動重試」就是一句空話 —— 而使用者會合理地以為
|
|
143
|
+
// 自己不用再做什麼。第一台 Windows 機器就是這樣卡住的:憑證修好之後仍然
|
|
144
|
+
// 不會有任何資料,因為沒有人再跑一次 enroll。
|
|
145
|
+
try {
|
|
146
|
+
loadToken();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// 憑證還是壞的 -> 這次 enroll 必定失敗,安靜跳過,不要每 5 分鐘去騷擾 server
|
|
150
|
+
return "not-enrolled";
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
if ((await enroll([])) !== 0)
|
|
154
|
+
return "enroll-failed";
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return "enroll-failed";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
142
160
|
try {
|
|
143
161
|
return (await report([])) === 0 ? "uploaded" : "upload-failed";
|
|
144
162
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
13
13
|
import { homedir, platform, release, userInfo } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { EXIT, loadToken } from "./creds.js";
|
|
16
|
+
import { desktopDataDirs } from "./creds-win.js";
|
|
16
17
|
import { claudeDir, claudeSettingsPath, configPath, credsPath, devicePath, projectsDir, serverUrl, stateDir, } from "./paths.js";
|
|
17
18
|
import { healthWarning, loadHealth } from "./health.js";
|
|
18
19
|
import { readJson } from "./util.js";
|
|
@@ -86,6 +87,31 @@ export async function doctor() {
|
|
|
86
87
|
out.push(info(`憑證主儲存 ${isMac ? "macOS Keychain(檔案只是副本,可能過時)" : "檔案"}`));
|
|
87
88
|
out.push(info(`憑證檔 ${cp} -> ${existsSync(cp) ? `存在 ${statSync(cp).size} bytes` : "不存在"}` +
|
|
88
89
|
(!existsSync(cp) && !isMac ? " <- 這個平台只有檔案這條路,所以是問題" : "")));
|
|
90
|
+
// ── Windows 桌面版(MSIX)──
|
|
91
|
+
// 只用桌面版、沒登入過 CLI 的人,.credentials.json 裡根本不會有 claudeAiOauth。
|
|
92
|
+
// 這一段把「桌面版憑證庫在哪、有沒有東西」攤開,否則診斷又會退回「猜 + 手寫指令」。
|
|
93
|
+
if (platform() === "win32") {
|
|
94
|
+
const dirs = desktopDataDirs();
|
|
95
|
+
if (!dirs.length) {
|
|
96
|
+
out.push(info("桌面版 找不到資料目錄(沒裝桌面版就正常)。MSIX 會把 %APPDATA%\\Claude " +
|
|
97
|
+
"重導到 %LOCALAPPDATA%\\Packages\\Claude_*\\LocalCache\\Roaming\\Claude"));
|
|
98
|
+
}
|
|
99
|
+
for (const { dir, kind } of dirs) {
|
|
100
|
+
out.push(info(`桌面版 ${dir} (${kind})`));
|
|
101
|
+
const ls = join(dir, "Local State");
|
|
102
|
+
const cfgPath = join(dir, "config.json");
|
|
103
|
+
out.push(info(` Local State ${existsSync(ls) ? "存在" : "不存在"} / ` +
|
|
104
|
+
`config.json ${existsSync(cfgPath) ? `${statSync(cfgPath).size} bytes` : "不存在"}`));
|
|
105
|
+
// 只印「有沒有這個鍵」,不印值 —— 值是加密的 token。
|
|
106
|
+
const cfg = readJson(cfgPath);
|
|
107
|
+
if (cfg) {
|
|
108
|
+
const keys = ["oauth:tokenCacheV2", "oauth:tokenCache"].filter((k) => cfg[k]);
|
|
109
|
+
out.push(keys.length
|
|
110
|
+
? info(` token cache 鍵: ${keys.join(", ")}`)
|
|
111
|
+
: bad(` config.json 裡沒有 oauth:tokenCache* —— 桌面版可能沒登入`));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
89
115
|
let credOk = false;
|
|
90
116
|
try {
|
|
91
117
|
const t = loadToken();
|
|
@@ -99,7 +125,8 @@ export async function doctor() {
|
|
|
99
125
|
out.push(` ${line.trim()}`);
|
|
100
126
|
problems.push(err.code === EXIT.TOKEN_EXPIRED
|
|
101
127
|
? "token 過期 —— 在 Claude Code 裡跑 /login"
|
|
102
|
-
: "
|
|
128
|
+
: "讀不到憑證(細節見上)。本工具需要**帳號登入**:終端機 /login,或桌面版登入;" +
|
|
129
|
+
"用 API key / Bedrock / Vertex 時取不到額度資訊");
|
|
103
130
|
}
|
|
104
131
|
// ── session 資料 ──
|
|
105
132
|
const pd = projectsDir();
|
package/dist/install.js
CHANGED
|
@@ -230,7 +230,12 @@ async function enrollStep(argv) {
|
|
|
230
230
|
throw new Error("enroll returned non-zero");
|
|
231
231
|
}
|
|
232
232
|
catch (e) {
|
|
233
|
-
|
|
233
|
+
// 這句話必須與 daemon.flushUploads 的實際行為一致。它曾經承諾「會自動重試」
|
|
234
|
+
// 而程式碼並不會,於是卡住的人以為自己不用再做什麼。
|
|
235
|
+
console.error(`enroll: FAILED — ${e.message}`);
|
|
236
|
+
console.error(" hook 與背景任務仍會安裝。憑證問題解決後,背景任務會在 5 分鐘內自動完成註冊," +
|
|
237
|
+
"不需要重跑 install。\n" +
|
|
238
|
+
" 先跑 claude-usage doctor 看是哪一種問題。");
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
241
|
export async function install(argv = []) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsa-group/claude-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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
6
|
"bin": {
|