@tsa-group/claude-usage 0.4.7 → 0.4.9

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 CHANGED
@@ -350,6 +350,12 @@ v0.4.7 起支援自我更新,但**由伺服器決定**,不是去問 npm:
350
350
  ## 已知限制
351
351
 
352
352
  - **非官方 endpoint**:額度 % 來自 Claude Code 內部的 `/api/oauth/usage`,Anthropic 可能變更。
353
+ - **macOS 桌面版憑證也是逆向出來的**:Claude.app 把 OAuth token 存在
354
+ `~/Library/Application Support/Claude/config.json` 的 `oauth:tokenCacheV2`,用 Chromium 的
355
+ macOS 方案(Keychain「Claude Safe Storage」主金鑰 + PBKDF2-SHA1 + AES-128-CBC)加密。
356
+ 首次背景存取會彈 Keychain 授權對話 —— 在終端機跑一次
357
+ `security find-generic-password -w -s "Claude Safe Storage"` 並按**一律允許**即可,
358
+ 之後 daemon 讀得到。`doctor` 會偵測這個狀態並提示。與 `/api/oauth/usage` 同一風險類別。
353
359
  - **Windows 桌面版憑證是逆向出來的**:桌面版把 OAuth token 存在
354
360
  `%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\config.json` 的
355
361
  `oauth:tokenCacheV2`,以 Chromium 的 `v10` 方案(DPAPI + AES-256-GCM)加密。
@@ -362,6 +368,9 @@ v0.4.7 起支援自我更新,但**由伺服器決定**,不是去問 npm:
362
368
  (實測:2026-08-29 四台 Win11 因此全數斷線 10.7–18.1 小時,其中一台累計只跑過 7 次。)
363
369
  - **重疊的 tick 尚未互斥**:VBS 以非阻塞方式啟動 node,工作排程器認為任務瞬間完成,
364
370
  所以 `MultipleInstances` 擋不住重疊的 node 行程。極少數情況下會競寫 `health.json`。
371
+ - **npm 11 起不跑 postinstall**:安裝時會印 `npm warn allow-scripts`,我們的安裝提示
372
+ 因此不會顯示。同樣的檢查已搬進 `claude-usage install` 與 `doctor`,所以裝完務必
373
+ 跑一次 `install`。
365
374
  - **PowerShell 執行原則**:預設 `Restricted` 會同時擋掉 `npm.ps1`(安裝時)與
366
375
  `claude-usage.ps1`(執行時)。處理方式見上面 **Windows → 第 0 步**。
367
376
  - **Linux 背景任務未實作**(systemd --user timer)。其餘指令可用。
@@ -0,0 +1,148 @@
1
+ /**
2
+ * macOS 桌面版(Claude.app)的 Claude OAuth 憑證讀取。
3
+ *
4
+ * 為什麼需要這條路徑:跟 Windows 桌面版是**同一個問題**(見 creds-win.ts 檔頭)——
5
+ * 只用桌面版、沒在終端機開過 CLI 的人,`~/.claude/.credentials.json` 裡不會有可用的
6
+ * token,而 Keychain 裡那份是早期 CLI 登入的殘留,沒人 refresh 就會過期。實機
7
+ * (2026-08-31, Ashlyn)就是這樣:Claude Code 照常運作(它自己 refresh),但我們讀到
8
+ * 的 Keychain 憑證從 02:00 起過期,採樣連續 token_expired,而她其實一直在用。
9
+ *
10
+ * 桌面版把 token 存在 `~/Library/Application Support/Claude/config.json` 的
11
+ * `oauth:tokenCacheV2`,用 Chromium 的 macOS 加密方案:
12
+ * 主金鑰 = PBKDF2-HMAC-SHA1(Keychain「Claude Safe Storage」密碼, "saltysalt", 1003 輪, 16B)
13
+ * 密文 = base64( "v10"(3B) + AES-128-CBC(IV = 16 個 0x20) ) + PKCS7
14
+ *
15
+ * ★ 與 Windows 版的唯一差異就是加密那一步(DPAPI+AES-256-GCM ↔ Keychain+AES-128-CBC)。
16
+ * 解出來的 JSON 形狀完全相同(實機驗證:2 條目、user:inference 那筆是目標),所以
17
+ * **挑選邏輯直接重用 creds-win.ts 的 pickEntry**,不另寫一份。
18
+ *
19
+ * ★ 為什麼主金鑰用 `security` CLI 而不是 native Keychain API:與 mac 上讀 Keychain
20
+ * token、Windows 上 spawn PowerShell 做 DPAPI 完全對稱 —— 零執行期依賴、免編譯。
21
+ *
22
+ * ★ 為什麼 AES-GCM 用 Node crypto 而 KDF 用 Node crypto、只有取主金鑰密碼 spawn
23
+ * 外部程式:跟 Windows 版同一個分工原則 —— 只有「拿受作業系統保護的秘密」那一步
24
+ * 非得走系統工具,其餘純運算留在 Node,測得到、也不受外部工具版本影響。
25
+ *
26
+ * ⚠️ 未公開的內部格式,Anthropic 改版就可能失效 —— 與 /api/oauth/usage 同一風險類別。
27
+ * 失敗一律回可辨識的 source 字串,不拋例外把 daemon 打掛。
28
+ *
29
+ * SECURITY:解出的 master key 與 token 只留在記憶體,**永不列印、永不寫檔、永不上傳**。
30
+ */
31
+ import { spawnSync } from "node:child_process";
32
+ import { createDecipheriv, pbkdf2Sync } from "node:crypto";
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { pickEntry } from "./creds-win.js";
37
+ /** Chromium 加密區塊前綴。看到別的就是格式變了,不硬解。 */
38
+ const V10 = "v10";
39
+ /** Chromium 在 macOS 的固定 KDF 參數(公開常數,非機密)。 */
40
+ const KDF_SALT = "saltysalt";
41
+ const KDF_ITER = 1003;
42
+ const KDF_KEYLEN = 16; // AES-128
43
+ /** 桌面版憑證檔位置。Claude.app 走 Electron 的 userData 慣例。 */
44
+ function configPath() {
45
+ return join(homedir(), "Library", "Application Support", "Claude", "config.json");
46
+ }
47
+ /**
48
+ * 從 Keychain 取「Claude Safe Storage」密碼,PBKDF2 成 16-byte AES 金鑰。
49
+ *
50
+ * `security find-generic-password -w -s <svc>` 只回密碼本體。首次存取可能跳授權
51
+ * 對話(App 首次要求時使用者已同意過,daemon 走同一個 ACL),非互動下拿不到就回
52
+ * 可辨識原因,不卡住。
53
+ */
54
+ function masterKey() {
55
+ const r = spawnSync("security", ["find-generic-password", "-w", "-s", "Claude Safe Storage"],
56
+ // ★ timeout 短是刻意的:`Claude Safe Storage` 的 Keychain ACL 只授權給 Claude.app,
57
+ // 從別的行程存取會**彈授權對話框並阻塞等點擊**。daemon 是非互動的,等不到人點,
58
+ // 所以與其掛在那裡耗一個 tick,不如快速 timeout 回一個可辨識的原因。
59
+ // 使用者只要在 Keychain 存取.app 對這個項目按一次「一律允許」,之後就不再彈。
60
+ { encoding: "utf8", timeout: 8_000 });
61
+ // ETIMEDOUT 幾乎必然是「在等授權對話」,與「security 不存在」是兩種不同處置:
62
+ // 前者要使用者去 Keychain 授權一次,後者是環境壞了。分開回報。
63
+ if (r.error) {
64
+ const timedOut = r.error.code === "ETIMEDOUT";
65
+ return [null, timedOut ? "keychain-needs-authorization" : "keychain-spawn-failed"];
66
+ }
67
+ // 非零通常是「找不到項目」(44) 或「使用者拒絕授權」(128)。分開讓資料看得出哪一種。
68
+ if (r.status !== 0) {
69
+ const code = r.status === 44 ? "no-item" : r.status === 128 ? "denied" : `rc${r.status}`;
70
+ return [null, `keychain-${code}`];
71
+ }
72
+ const pw = (r.stdout ?? "").trim();
73
+ if (!pw)
74
+ return [null, "keychain-empty"];
75
+ try {
76
+ const key = pbkdf2Sync(pw, KDF_SALT, KDF_ITER, KDF_KEYLEN, "sha1");
77
+ return [key, "ok"];
78
+ }
79
+ catch {
80
+ return [null, "kdf-failed"];
81
+ }
82
+ }
83
+ /**
84
+ * 拆 v10 區塊並用 AES-128-CBC 解密。
85
+ * 結構:`"v10"`(3B) │ ciphertext(16 的倍數,含 PKCS7)。IV 固定為 16 個 0x20。
86
+ *
87
+ * 與 Windows 的 decryptV10 分開實作而非共用:那邊是 GCM(有 nonce/tag 切割),
88
+ * 這邊是 CBC(固定 IV、無 tag),共用只會讓兩邊都變成一堆 if。挑選邏輯才是該共用的
89
+ * 部分,那個已經共用(pickEntry)。
90
+ */
91
+ export function decryptDesktopMac(b64, key) {
92
+ let blob;
93
+ try {
94
+ blob = Buffer.from(b64, "base64");
95
+ }
96
+ catch {
97
+ return [null, "cache-bad-base64"];
98
+ }
99
+ if (blob.subarray(0, 3).toString("latin1") !== V10) {
100
+ return [null, `cache-not-v10:${blob.subarray(0, 3).toString("latin1")}`];
101
+ }
102
+ const ct = blob.subarray(3);
103
+ // CBC 密文長度必為 16 的倍數且至少一個區塊。不符 = 資料壞了,別餵給 decipher。
104
+ if (ct.length === 0 || ct.length % 16 !== 0)
105
+ return [null, `cache-bad-ct-len:${ct.length}`];
106
+ try {
107
+ const iv = Buffer.alloc(16, 0x20); // 16 個空白
108
+ const d = createDecipheriv("aes-128-cbc", key, iv);
109
+ // autoPadding 預設 true,Node 會驗並剝 PKCS7;padding 不對會在 final() 拋。
110
+ return [Buffer.concat([d.update(ct), d.final()]).toString("utf8"), "ok"];
111
+ }
112
+ catch {
113
+ // PKCS7 驗證失敗 = 金鑰不對或資料被改過。不要當成「格式變了」。
114
+ return [null, "cache-decrypt-failed"];
115
+ }
116
+ }
117
+ /**
118
+ * macOS 桌面版憑證。回 `[creds, source]`;失敗時 creds 為 null、source 是可辨識原因
119
+ * (會記進 health / cred_source,之後從資料就看得出卡在哪一步)。
120
+ */
121
+ export function fromMacDesktop() {
122
+ if (process.platform !== "darwin")
123
+ return [null, "not-macos"];
124
+ const cfgPath = configPath();
125
+ if (!existsSync(cfgPath))
126
+ return [null, "desktop-no-config"];
127
+ let cfg;
128
+ try {
129
+ cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
130
+ }
131
+ catch {
132
+ return [null, "desktop-config-unreadable"];
133
+ }
134
+ // V2 是新版;舊版鍵名無 V2 後綴。先新後舊,與 Windows 版一致。
135
+ const cached = cfg["oauth:tokenCacheV2"] ?? cfg["oauth:tokenCache"];
136
+ if (typeof cached !== "string" || !cached)
137
+ return [null, "desktop-no-token-cache"];
138
+ const [key, keyWhy] = masterKey();
139
+ if (!key)
140
+ return [null, `desktop-${keyWhy}`];
141
+ const [plain, decWhy] = decryptDesktopMac(cached, key);
142
+ if (!plain)
143
+ return [null, `desktop-${decWhy}`];
144
+ const [creds, pickWhy] = pickEntry(plain); // ★ 與 Windows 共用挑選邏輯
145
+ if (!creds)
146
+ return [null, `desktop-${pickWhy}`];
147
+ return [creds, "desktop-mac"];
148
+ }
package/dist/creds.js CHANGED
@@ -14,6 +14,7 @@ import { userInfo } from "node:os";
14
14
  import { readFileSync } from "node:fs";
15
15
  import { claudeDir, credsPath } from "./paths.js";
16
16
  import { fromWindowsDesktop } from "./creds-win.js";
17
+ import { fromMacDesktop } from "./creds-mac-desktop.js";
17
18
  /** 可區分的結束碼 —— daemon 靠這個把「token 過期」和「網路壞掉」分開記錄。
18
19
  * 過去兩者都是 exit 1,daemon 只能寫 sample-failed,於是 2026-08-22 那次
19
20
  * token 過期靜默死了 17.6 小時沒人發現。 */
@@ -111,7 +112,9 @@ const expired = (o) => Boolean(o.expiresAt && Date.now() >= o.expiresAt);
111
112
  const SOURCES = [
112
113
  fromKeychain, // macOS 主儲存
113
114
  fromFile, // CLI 的檔案(Windows/Linux 是主儲存;mac 上只是過時副本)
114
- fromWindowsDesktop, // Windows 桌面版(MSIX,加密)
115
+ fromWindowsDesktop, // Windows 桌面版(MSIX,DPAPI+AES-GCM)
116
+ fromMacDesktop, // macOS 桌面版(Keychain+AES-CBC)—— 排最後:只有前面都過期/沒有
117
+ // 才需要它,且取 Keychain 主金鑰有成本,不該每次都跑
115
118
  ];
116
119
  export function loadToken() {
117
120
  const why = [];
package/dist/doctor.js CHANGED
@@ -15,6 +15,7 @@ import { homedir, platform, release, userInfo } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { EXIT, loadToken } from "./creds.js";
17
17
  import { desktopDataDirs } from "./creds-win.js";
18
+ import { fromMacDesktop } from "./creds-mac-desktop.js";
18
19
  import { claudeDir, claudeSettingsPath, configPath, credsPath, devicePath, projectsDir, serverUrl, stateDir, } from "./paths.js";
19
20
  import { healthWarning, loadHealth } from "./health.js";
20
21
  import { readJson } from "./util.js";
@@ -113,6 +114,31 @@ export async function doctor() {
113
114
  out.push(ok(`執行原則 ${policy}(不會擋 .ps1 啟動器)`));
114
115
  }
115
116
  }
117
+ // ── macOS 桌面版(Claude.app)──
118
+ // 與 Windows 段對稱:只用桌面版、沒開過 CLI 的 mac 使用者,Keychain 那份是舊 CLI
119
+ // 殘留、會過期,真正的活 token 在桌面版加密庫裡。把狀態攤開,否則診斷會誤判成
120
+ // 「token 過期,去 /login」——但那治標不治本。
121
+ if (platform() === "darwin") {
122
+ const cfg = join(homedir(), "Library", "Application Support", "Claude", "config.json");
123
+ if (!existsSync(cfg)) {
124
+ out.push(info("桌面版 找不到 config.json(沒裝桌面版就正常)"));
125
+ }
126
+ else {
127
+ const [creds, why] = fromMacDesktop();
128
+ if (creds) {
129
+ out.push(ok(`桌面版 憑證可讀(來源 ${why}, tier=${creds.rateLimitTier})`));
130
+ }
131
+ else if (why === "desktop-keychain-needs-authorization") {
132
+ out.push(bad("桌面版 Keychain 需要授權一次"));
133
+ problems.push("macOS 桌面版憑證庫需要授權:在終端機跑一次 " +
134
+ '`security find-generic-password -w -s "Claude Safe Storage"`,' +
135
+ "跳出對話框時按**一律允許**(Always Allow)。之後背景採樣就讀得到。");
136
+ }
137
+ else {
138
+ out.push(info(`桌面版 ${why}`));
139
+ }
140
+ }
141
+ }
116
142
  // ── Windows 桌面版(MSIX)──
117
143
  // 只用桌面版、沒登入過 CLI 的人,.credentials.json 裡根本不會有 claudeAiOauth。
118
144
  // 這一段把「桌面版憑證庫在哪、有沒有東西」攤開,否則診斷又會退回「猜 + 手寫指令」。
package/dist/install.js CHANGED
@@ -141,7 +141,32 @@ function winHardenTask() {
141
141
  "-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable)");
142
142
  return false;
143
143
  }
144
+ /**
145
+ * 安裝時檢查 PowerShell 執行原則。
146
+ *
147
+ * ★ 為什麼這段從 postinstall.mjs 搬到這裡:npm 11 起預設**不執行**未核准的
148
+ * postinstall 腳本(安裝時只印 `npm warn allow-scripts`),所以那支檔案在新版 npm
149
+ * 上根本不會跑 —— 它原本的唯一價值(在使用者撞牆前先講)已經失效。
150
+ * `install` 是 `npm i -g` 之後緊接著要跑的指令,把提示放這裡才真的看得到。
151
+ *
152
+ * ★ 為什麼仍然要提示而不是自動修:改動使用者機器的安全設定,該由他自己決定。
153
+ */
154
+ function warnExecutionPolicy() {
155
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], { encoding: "utf8", timeout: 10_000, windowsHide: true });
156
+ const policy = r.error || r.status !== 0 ? null : (r.stdout ?? "").trim();
157
+ // 只在**實際會擋**的機器上印。無條件每次都印,就是在訓練人忽略它。
158
+ if (policy !== "Restricted" && policy !== "AllSigned")
159
+ return;
160
+ console.log(`\n! PowerShell 執行原則是 ${policy} —— 直接打 claude-usage 會被擋\n` +
161
+ " (錯誤含 UnauthorizedAccess / PSSecurityException / ExecutionPolicy)\n" +
162
+ " 擇一處理:\n" +
163
+ " 1) Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned\n" +
164
+ " 設定一次、永久生效、免系統管理員。\n" +
165
+ " 2) 不改原則,改叫 claude-usage.cmd(功能完全一樣)\n" +
166
+ " 公司用 GPO 鎖了機器層級原則時 (1) 會被覆寫 —— 直接用 (2)。\n");
167
+ }
144
168
  function winInstall() {
169
+ warnExecutionPolicy(); // 先講,因為接下來每一步都可能因為它而失敗
145
170
  const vbs = `Set s = CreateObject("Wscript.Shell")\r\n` +
146
171
  `s.Run """${NODE}"" ""${CLI}"" daemon-tick", 0, False\r\n`;
147
172
  writeFileSync(VBS_PATH(), vbs, "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsa-group/claude-usage",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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": {
package/postinstall.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * 安裝後提示:Windows 的 PowerShell 執行原則會擋掉 npm 產生的 .ps1 啟動器。
3
3
  *
4
+ * ⚠️ npm 11 起預設**不執行**未核准的 postinstall(只印 `npm warn allow-scripts`),
5
+ * 所以這支檔案在新版 npm 上不會跑。同樣的檢查已於 v0.4.8 搬進 `install`
6
+ * 與 `doctor`(見 src/install.ts warnExecutionPolicy)。這裡保留是因為在
7
+ * 舊版 npm 上它仍然是唯一能「撞牆之前先說話」的時機。
8
+ *
4
9
  * ★ 為什麼一定要在這裡講:這個失敗發生時**我們的程式碼根本沒機會執行** ——
5
10
  * PowerShell 在 Node 啟動前就拒絕載入 `claude-usage.ps1` 了。所以工具永遠無法在
6
11
  * 出錯當下自我診斷,提示只能前置,而 postinstall 是唯一「撞牆之前還能說話」的