@tsa-group/claude-usage 0.3.5 → 0.4.1
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 +51 -3
- package/dist/creds-win.js +249 -0
- package/dist/creds.js +56 -28
- package/dist/doctor.js +49 -1
- package/package.json +5 -3
- package/postinstall.mjs +59 -0
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
|
---
|
|
@@ -51,6 +64,34 @@ claude-usage install
|
|
|
51
64
|
claude-usage status
|
|
52
65
|
```
|
|
53
66
|
|
|
67
|
+
### Windows / PowerShell 首次執行
|
|
68
|
+
|
|
69
|
+
多數 Windows 的 PowerShell 執行原則預設是 `Restricted`,會擋掉 npm 產生的 `.ps1`
|
|
70
|
+
啟動器 —— **安裝成功、一執行就被擋**:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
因為這個系統上已停用指令碼執行,所以無法載入 ...\npm\claude-usage.ps1
|
|
74
|
+
+ FullyQualifiedErrorId : UnauthorizedAccess (PSSecurityException)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
這**不是套件的問題**,`cmd.exe` 與 Node 本身都不受影響。擇一處理:
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
# 1) 設定一次、永久生效、免系統管理員(推薦)
|
|
81
|
+
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
|
|
82
|
+
|
|
83
|
+
# 2) 不改原則,改叫 .cmd 啟動器(功能完全一樣)
|
|
84
|
+
claude-usage.cmd status
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`RemoteSigned` = 本機與 npm 的腳本可跑,只擋網路下載的未簽章腳本。
|
|
88
|
+
公司用 GPO 鎖了機器層級原則時 (1) 會被覆寫 —— 直接用 (2)。
|
|
89
|
+
現況查詢:`Get-ExecutionPolicy -List`
|
|
90
|
+
|
|
91
|
+
> 安裝時若偵測到會擋的原則,`postinstall` 會主動提醒;事後也可用
|
|
92
|
+
> `claude-usage.cmd doctor` 檢查。**但工具無法在被擋的當下自己說話** ——
|
|
93
|
+
> PowerShell 在 Node 啟動前就拒絕了,所以這段提示只能寫在這裡。
|
|
94
|
+
|
|
54
95
|
**兩個可能讓你以為「裝壞了」的情況:**
|
|
55
96
|
|
|
56
97
|
- **server 開了人工核准** → `enroll` 會回 `status: pending`,你的資料要等管理者核准後才
|
|
@@ -202,9 +243,16 @@ Client 對 server 只用兩個端點,皆為 `application/json`:
|
|
|
202
243
|
## 已知限制
|
|
203
244
|
|
|
204
245
|
- **非官方 endpoint**:額度 % 來自 Claude Code 內部的 `/api/oauth/usage`,Anthropic 可能變更。
|
|
205
|
-
- **Windows
|
|
206
|
-
|
|
246
|
+
- **Windows 桌面版憑證是逆向出來的**:桌面版把 OAuth token 存在
|
|
247
|
+
`%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\config.json` 的
|
|
248
|
+
`oauth:tokenCacheV2`,以 Chromium 的 `v10` 方案(DPAPI + AES-256-GCM)加密。
|
|
249
|
+
這是**未公開的內部格式**,Anthropic 改版就可能失效 —— 與 `/api/oauth/usage` 同一個
|
|
250
|
+
風險類別。失效時會在 `claude-usage doctor` 顯示卡在哪一步,不會靜默。
|
|
251
|
+
- **Windows 背景任務未完整實機驗證**:schtasks + VBS 隱藏視窗啟動器。裝完請用
|
|
207
252
|
`claude-usage status` 確認有心跳。
|
|
253
|
+
- **PowerShell 執行原則**:預設 `Restricted` 會同時擋掉 `npm.ps1`(安裝時)與
|
|
254
|
+
`claude-usage.ps1`(執行時)。安裝用 `npm.cmd i -g …`;其餘見上面
|
|
255
|
+
「Windows / PowerShell 首次執行」。
|
|
208
256
|
- **Linux 背景任務未實作**(systemd --user timer)。其餘指令可用。
|
|
209
257
|
- **`session_id` 跨 compaction / resume 不穩定**:session **數**會高估。token 與成本不受影響
|
|
210
258
|
(那是 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/doctor.js
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
* 路徑對不對是使用者一眼就能判斷的事(「那不是我的 Claude Code 裝的地方」),
|
|
10
10
|
* 但前提是我們得把路徑印出來。
|
|
11
11
|
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
12
13
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
13
14
|
import { homedir, platform, release, userInfo } from "node:os";
|
|
14
15
|
import { join } from "node:path";
|
|
15
16
|
import { EXIT, loadToken } from "./creds.js";
|
|
17
|
+
import { desktopDataDirs } from "./creds-win.js";
|
|
16
18
|
import { claudeDir, claudeSettingsPath, configPath, credsPath, devicePath, projectsDir, serverUrl, stateDir, } from "./paths.js";
|
|
17
19
|
import { healthWarning, loadHealth } from "./health.js";
|
|
18
20
|
import { readJson } from "./util.js";
|
|
@@ -86,6 +88,51 @@ export async function doctor() {
|
|
|
86
88
|
out.push(info(`憑證主儲存 ${isMac ? "macOS Keychain(檔案只是副本,可能過時)" : "檔案"}`));
|
|
87
89
|
out.push(info(`憑證檔 ${cp} -> ${existsSync(cp) ? `存在 ${statSync(cp).size} bytes` : "不存在"}` +
|
|
88
90
|
(!existsSync(cp) && !isMac ? " <- 這個平台只有檔案這條路,所以是問題" : "")));
|
|
91
|
+
// ── Windows:PowerShell 執行原則 ──
|
|
92
|
+
// 這個問題有個特別惡劣的性質:**它發生時我們的程式碼根本沒機會執行** ——
|
|
93
|
+
// PowerShell 在 Node 啟動前就拒絕載入 claude-usage.ps1。所以工具無法在出錯當下
|
|
94
|
+
// 自我診斷,只能在 postinstall 事先講、或在這裡(使用者改用 .cmd 跑得起來時)補講。
|
|
95
|
+
if (platform() === "win32") {
|
|
96
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], { encoding: "utf8", timeout: 10_000, windowsHide: true });
|
|
97
|
+
const policy = r.error || r.status !== 0 ? null : (r.stdout ?? "").trim();
|
|
98
|
+
if (!policy) {
|
|
99
|
+
out.push(info("執行原則 查不到(powershell.exe 不可用?)"));
|
|
100
|
+
}
|
|
101
|
+
else if (policy === "Restricted" || policy === "AllSigned") {
|
|
102
|
+
out.push(bad(`執行原則 ${policy} —— PowerShell 會擋掉 claude-usage.ps1 啟動器`));
|
|
103
|
+
problems.push(`PowerShell 執行原則是 ${policy}(錯誤含 UnauthorizedAccess / ExecutionPolicy)。` +
|
|
104
|
+
`擇一:Set-ExecutionPolicy -Scope CurrentUser RemoteSigned(免管理員),` +
|
|
105
|
+
`或直接用 claude-usage.cmd。公司 GPO 鎖機器層級時只能用後者`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
out.push(ok(`執行原則 ${policy}(不會擋 .ps1 啟動器)`));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ── Windows 桌面版(MSIX)──
|
|
112
|
+
// 只用桌面版、沒登入過 CLI 的人,.credentials.json 裡根本不會有 claudeAiOauth。
|
|
113
|
+
// 這一段把「桌面版憑證庫在哪、有沒有東西」攤開,否則診斷又會退回「猜 + 手寫指令」。
|
|
114
|
+
if (platform() === "win32") {
|
|
115
|
+
const dirs = desktopDataDirs();
|
|
116
|
+
if (!dirs.length) {
|
|
117
|
+
out.push(info("桌面版 找不到資料目錄(沒裝桌面版就正常)。MSIX 會把 %APPDATA%\\Claude " +
|
|
118
|
+
"重導到 %LOCALAPPDATA%\\Packages\\Claude_*\\LocalCache\\Roaming\\Claude"));
|
|
119
|
+
}
|
|
120
|
+
for (const { dir, kind } of dirs) {
|
|
121
|
+
out.push(info(`桌面版 ${dir} (${kind})`));
|
|
122
|
+
const ls = join(dir, "Local State");
|
|
123
|
+
const cfgPath = join(dir, "config.json");
|
|
124
|
+
out.push(info(` Local State ${existsSync(ls) ? "存在" : "不存在"} / ` +
|
|
125
|
+
`config.json ${existsSync(cfgPath) ? `${statSync(cfgPath).size} bytes` : "不存在"}`));
|
|
126
|
+
// 只印「有沒有這個鍵」,不印值 —— 值是加密的 token。
|
|
127
|
+
const cfg = readJson(cfgPath);
|
|
128
|
+
if (cfg) {
|
|
129
|
+
const keys = ["oauth:tokenCacheV2", "oauth:tokenCache"].filter((k) => cfg[k]);
|
|
130
|
+
out.push(keys.length
|
|
131
|
+
? info(` token cache 鍵: ${keys.join(", ")}`)
|
|
132
|
+
: bad(` config.json 裡沒有 oauth:tokenCache* —— 桌面版可能沒登入`));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
89
136
|
let credOk = false;
|
|
90
137
|
try {
|
|
91
138
|
const t = loadToken();
|
|
@@ -99,7 +146,8 @@ export async function doctor() {
|
|
|
99
146
|
out.push(` ${line.trim()}`);
|
|
100
147
|
problems.push(err.code === EXIT.TOKEN_EXPIRED
|
|
101
148
|
? "token 過期 —— 在 Claude Code 裡跑 /login"
|
|
102
|
-
: "
|
|
149
|
+
: "讀不到憑證(細節見上)。本工具需要**帳號登入**:終端機 /login,或桌面版登入;" +
|
|
150
|
+
"用 API key / Bedrock / Vertex 時取不到額度資訊");
|
|
103
151
|
}
|
|
104
152
|
// ── session 資料 ──
|
|
105
153
|
const pd = projectsDir();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsa-group/claude-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
-
"README.md"
|
|
11
|
+
"README.md",
|
|
12
|
+
"postinstall.mjs"
|
|
12
13
|
],
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=20"
|
|
@@ -17,7 +18,8 @@
|
|
|
17
18
|
"build": "tsc -p tsconfig.json",
|
|
18
19
|
"test": "node --test test/*.test.ts",
|
|
19
20
|
"prepublishOnly": "npm run build && npm test",
|
|
20
|
-
"check": "tsc -p tsconfig.json --noEmit"
|
|
21
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"postinstall": "node postinstall.mjs"
|
|
21
23
|
},
|
|
22
24
|
"keywords": [
|
|
23
25
|
"claude",
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安裝後提示:Windows 的 PowerShell 執行原則會擋掉 npm 產生的 .ps1 啟動器。
|
|
3
|
+
*
|
|
4
|
+
* ★ 為什麼一定要在這裡講:這個失敗發生時**我們的程式碼根本沒機會執行** ——
|
|
5
|
+
* PowerShell 在 Node 啟動前就拒絕載入 `claude-usage.ps1` 了。所以工具永遠無法在
|
|
6
|
+
* 出錯當下自我診斷,提示只能前置,而 postinstall 是唯一「撞牆之前還能說話」的
|
|
7
|
+
* 時機(它由 Node 執行,不受 .ps1 原則管制)。
|
|
8
|
+
*
|
|
9
|
+
* ★ 為什麼要先偵測而不是無條件印:無條件每次安裝都印一段警告,就是在訓練人忽略
|
|
10
|
+
* 它 —— 等真的出事那次也會被跳過。只在**實際會擋**的機器上印,訊息才有訊號價值。
|
|
11
|
+
*
|
|
12
|
+
* ★ 為什麼這支檔案刻意獨立、不 import dist/:postinstall 失敗會讓整個安裝失敗。
|
|
13
|
+
* 它必須在 dist 壞掉、Node 版本奇怪、或任何預期外的狀況下都安靜地成功。
|
|
14
|
+
* 與 doctor 裡那份偵測有少量重複,那是刻意的隔離。
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
// 這兩種原則會擋掉 .ps1 啟動器。RemoteSigned / Unrestricted / Bypass 都不會。
|
|
19
|
+
const BLOCKING = new Set(["Restricted", "AllSigned"]);
|
|
20
|
+
|
|
21
|
+
function effectivePolicy() {
|
|
22
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
timeout: 10_000,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
});
|
|
27
|
+
if (r.error || r.status !== 0) return null;
|
|
28
|
+
return (r.stdout ?? "").trim() || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
if (process.platform === "win32") {
|
|
33
|
+
const policy = effectivePolicy();
|
|
34
|
+
if (policy && BLOCKING.has(policy)) {
|
|
35
|
+
const L = [
|
|
36
|
+
"",
|
|
37
|
+
` ⚠ PowerShell 執行原則是 ${policy} —— 在 PowerShell 直接打 claude-usage 會被擋`,
|
|
38
|
+
" (錯誤訊息含 UnauthorizedAccess / PSSecurityException / ExecutionPolicy)",
|
|
39
|
+
"",
|
|
40
|
+
" 擇一處理:",
|
|
41
|
+
" 1) 設定一次、永久生效、免系統管理員:",
|
|
42
|
+
" Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned",
|
|
43
|
+
" RemoteSigned = 本機與 npm 的腳本可跑,只擋網路下載的未簽章腳本。",
|
|
44
|
+
" 2) 不改原則,改叫 .cmd 啟動器(功能完全一樣):",
|
|
45
|
+
" claude-usage.cmd status",
|
|
46
|
+
"",
|
|
47
|
+
" 公司用 GPO 鎖了機器層級原則時,(1) 會被覆寫 —— 直接用 (2)。",
|
|
48
|
+
" 現況查詢: Get-ExecutionPolicy -List",
|
|
49
|
+
"",
|
|
50
|
+
];
|
|
51
|
+
console.log(L.join("\n"));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// 提示壞掉絕不能讓安裝失敗
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 無論如何都成功收場
|
|
59
|
+
process.exitCode = 0;
|