@tsa-group/claude-usage 0.3.1 → 0.3.3

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/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 { credsPath } from "./paths.js";
15
- import { readJson } from "./util.js";
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 o = readJson(credsPath());
62
- if (!o)
63
- return [null, "file-error:unreadable"];
64
- return [(o.claudeAiOauth ?? {}), "file"];
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 hint = kcWhy === "keychain-locked"
78
- ? " -> 解鎖 login keychain(不是重新登入)"
79
- : kcWhy === "keychain-no-item"
80
- ? " -> 在 Claude Code /login"
81
- : "";
82
- throw new CuError(`cannot read credentials (keychain: ${kcWhy}; file: ${source})${hint}`, EXIT.NO_TOKEN);
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/daemon.js CHANGED
@@ -207,7 +207,11 @@ export function printHealth() {
207
207
  row("last_tick_at", h.last_tick_at);
208
208
  row("last_sample_at", h.last_sample_at);
209
209
  row("last_sample_status", h.last_sample_status);
210
- row("last_ok_at", h.last_ok_at + (h.last_ok_at_backfilled ? " (從既有快照回填,非真的成功紀錄)" : ""));
210
+ // 不能先字串相加再交給 row():h.last_ok_at undefined `undefined + ""` 會變成
211
+ // 字串 "undefined",把 row() 的 `?? "-"` 完全繞過去(Sandy 那台就印出 undefined)。
212
+ row("last_ok_at", h.last_ok_at
213
+ ? h.last_ok_at + (h.last_ok_at_backfilled ? " (從既有快照回填,非真的成功紀錄)" : "")
214
+ : "從未成功採樣過");
211
215
  row("consecutive_failures", h.consecutive_failures ?? 0);
212
216
  row("last_upload_state", h.last_upload_state);
213
217
  if (h.last_error)
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, enrollSecret } from "./upload.js";
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
- const r = spawnSync("schtasks", args, { encoding: "utf8" });
107
+ // 不設 encoding:Windows 主控台是本地碼頁(繁中是 CP950),用 utf8 解碼會變亂碼,
108
+ // 而亂碼會把「真正的錯誤訊息」也一起藏掉。rc 才是可靠的訊號。
109
+ const r = spawnSync("schtasks", args);
108
110
  console.log(`wrote ${VBS_PATH()}`);
109
- console.log(`schtasks /Create: rc=${r.status} ${(r.stdout ?? r.stderr ?? "").trim()}`);
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 路徑尚未在真機驗證過 —— 裝完請跑 claude-usage status 確認有心跳");
122
+ console.log("! Windows 路徑尚未在真機完整驗證 —— 裝完請跑 claude-usage status 確認有心跳");
112
123
  return r.status ?? 1;
113
124
  }
114
125
  function winUninstall() {
@@ -118,9 +129,19 @@ function winUninstall() {
118
129
  return 0;
119
130
  }
120
131
  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;
132
+ // winInstall:不設 encoding。Windows 主控台是本地碼頁(繁中 CP950),用 utf8
133
+ // 解碼會把整段表格變成亂碼,而使用者要看的只是「這個工作在不在、下次何時跑」。
134
+ const r = spawnSync("schtasks", ["/Query", "/TN", WIN_TASK]);
135
+ if (r.status !== 0) {
136
+ console.log("bg-task: NOT installed (schtasks)");
137
+ return r.status ?? 1;
138
+ }
139
+ // 只挑出下次執行時間那一段數字/日期,避開會亂碼的本地化欄位標題
140
+ const raw = (r.stdout ?? Buffer.alloc(0)).toString("latin1");
141
+ const when = raw.match(/\d{4}[/-]\d{1,2}[/-]\d{1,2}[^\r\n]*/)?.[0]?.trim();
142
+ console.log(`bg-task: 已註冊 "${WIN_TASK}"${when ? `,下次執行 ${when}` : ""}`);
143
+ console.log(` 完整資訊: schtasks /Query /TN ${WIN_TASK} /V /FO LIST`);
144
+ return 0;
124
145
  }
125
146
  const sessionStartGroups = (s) => s.hooks?.SessionStart ?? [];
126
147
  /**
@@ -216,8 +237,9 @@ export async function install(argv = []) {
216
237
  const os = platform();
217
238
  console.log(`claude-usage install (${os})${isDry(argv) ? " [DRY-RUN]" : ""}`);
218
239
  console.log(` server = ${serverUrl()} state = ${stateDir()}`);
219
- if (!enrollSecret(argv) && !existsSync(devicePath())) {
220
- console.log(" ! server 若有設 enroll 密語,請加 --enroll-secret <密語>");
240
+ if (!existsSync(devicePath())) {
241
+ // 註冊需要管理者核准,同事看到「裝好了但沒資料」時第一個要想到的就是這件事。
242
+ console.log(" ! 首次註冊後需管理者核准,核准前你的資料不會進來(這是正常的)");
221
243
  }
222
244
  await enrollStep(argv);
223
245
  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(homedir(), ".claude", ".credentials.json");
46
+ export const credsPath = () => join(claudeDir(), ".credentials.json");
38
47
  /** Claude Code 的 session JSONL 根目錄。唯讀 */
39
- export const projectsDir = () => join(homedir(), ".claude", "projects");
48
+ export const projectsDir = () => join(claudeDir(), "projects");
40
49
  /** Claude Code 設定(SessionStart hook 註冊在這) */
41
- export const claudeSettingsPath = () => join(homedir(), ".claude", "settings.json");
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsa-group/claude-usage",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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": {