@tsa-group/claude-usage 0.3.3 → 0.3.5
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/cli.js +4 -0
- package/dist/daemon.js +22 -4
- package/dist/doctor.js +168 -0
- package/dist/install.js +6 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* prompt / 回應內容**永遠不離開這台機器**。
|
|
7
7
|
*/
|
|
8
8
|
import { printHealth, tick } from "./daemon.js";
|
|
9
|
+
import { doctor } from "./doctor.js";
|
|
9
10
|
import { install, status, uninstall } from "./install.js";
|
|
10
11
|
import { DEFAULT_SERVER, configPath, setConfig } from "./paths.js";
|
|
11
12
|
import { enroll, report } from "./upload.js";
|
|
@@ -27,6 +28,7 @@ const HELP = `claude-usage v${VERSION} — per-user Claude usage collector
|
|
|
27
28
|
enroll [--enroll-secret <密語>]
|
|
28
29
|
report [--full] [--dry-run]
|
|
29
30
|
health 背景任務心跳;不健康時 exit 1
|
|
31
|
+
doctor 自我診斷:印出解析到的路徑與那裡實際有什麼
|
|
30
32
|
uninstall 移除 hook 與背景任務
|
|
31
33
|
|
|
32
34
|
隱私:Claude OAuth token 與 prompt/回應內容永不離開本機。只送數字與帳號 email/uuid。`;
|
|
@@ -78,6 +80,8 @@ async function main() {
|
|
|
78
80
|
return 0;
|
|
79
81
|
case "health":
|
|
80
82
|
return printHealth();
|
|
83
|
+
case "doctor":
|
|
84
|
+
return await doctor();
|
|
81
85
|
case "enroll":
|
|
82
86
|
return await enroll(argv);
|
|
83
87
|
case "report":
|
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
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claude-usage doctor` — 把「為什麼不動」變成工具自己能回答的問題。
|
|
3
|
+
*
|
|
4
|
+
* 存在的理由:第一台 Windows 機器卡在憑證讀取時,錯誤訊息只說「讀不到」,於是
|
|
5
|
+
* 診斷變成「管理者手寫一段指令 → 請對方貼回來 → 猜 → 再寫一段」。這個迴圈每多一位
|
|
6
|
+
* 同事就重演一次,而卡住的人通常不知道自己卡住了。
|
|
7
|
+
*
|
|
8
|
+
* 原則:**印出「我解析到什麼路徑」與「那裡實際有什麼」,而不是只印結論。**
|
|
9
|
+
* 路徑對不對是使用者一眼就能判斷的事(「那不是我的 Claude Code 裝的地方」),
|
|
10
|
+
* 但前提是我們得把路徑印出來。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
13
|
+
import { homedir, platform, release, userInfo } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { EXIT, loadToken } from "./creds.js";
|
|
16
|
+
import { claudeDir, claudeSettingsPath, configPath, credsPath, devicePath, projectsDir, serverUrl, stateDir, } from "./paths.js";
|
|
17
|
+
import { healthWarning, loadHealth } from "./health.js";
|
|
18
|
+
import { readJson } from "./util.js";
|
|
19
|
+
import { VERSION } from "./version.js";
|
|
20
|
+
const ok = (s) => ` [ok] ${s}`;
|
|
21
|
+
const bad = (s) => ` [BAD] ${s}`;
|
|
22
|
+
const info = (s) => ` [info] ${s}`;
|
|
23
|
+
/** 目錄裡的 .jsonl 檔數與最新修改時間 —— 用來判斷 Claude Code 是不是真的在這裡跑 */
|
|
24
|
+
function jsonlSummary(dir) {
|
|
25
|
+
let files = 0;
|
|
26
|
+
let newest = null;
|
|
27
|
+
const walk = (d, depth) => {
|
|
28
|
+
if (depth > 3)
|
|
29
|
+
return;
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
const p = join(d, e.name);
|
|
39
|
+
if (e.isDirectory())
|
|
40
|
+
walk(p, depth + 1);
|
|
41
|
+
else if (e.name.endsWith(".jsonl")) {
|
|
42
|
+
files++;
|
|
43
|
+
try {
|
|
44
|
+
const m = statSync(p).mtime;
|
|
45
|
+
if (!newest || m > newest)
|
|
46
|
+
newest = m;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* 檔案在走訪途中被換掉是正常的 */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(dir, 0);
|
|
55
|
+
return { files, newest };
|
|
56
|
+
}
|
|
57
|
+
export async function doctor() {
|
|
58
|
+
const problems = [];
|
|
59
|
+
const out = [];
|
|
60
|
+
out.push(`claude-usage doctor v${VERSION}`);
|
|
61
|
+
out.push("");
|
|
62
|
+
out.push("環境");
|
|
63
|
+
out.push(info(`平台 ${platform()} ${release()} node ${process.version}`));
|
|
64
|
+
out.push(info(`使用者 ${userInfo().username}`));
|
|
65
|
+
out.push(info(`家目錄 ${homedir()}`));
|
|
66
|
+
// ── Claude Code 的設定目錄 ──
|
|
67
|
+
out.push("");
|
|
68
|
+
out.push("Claude Code 資料");
|
|
69
|
+
const fromEnv = Boolean(process.env["CLAUDE_CONFIG_DIR"]);
|
|
70
|
+
out.push(info(`設定目錄 ${claudeDir()}${fromEnv ? " <- 來自 CLAUDE_CONFIG_DIR" : " (預設)"}`));
|
|
71
|
+
if (!existsSync(claudeDir())) {
|
|
72
|
+
out.push(bad(`設定目錄不存在 —— Claude Code 沒有在這個使用者/這台機器上跑過`));
|
|
73
|
+
problems.push(`找不到 ${claudeDir()}。若你的 Claude Code 跑在 WSL 或別的使用者底下,` +
|
|
74
|
+
`本工具要裝在同一個環境裡才看得到資料`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
out.push(ok(`設定目錄存在`));
|
|
78
|
+
}
|
|
79
|
+
// ── 憑證 ──
|
|
80
|
+
// ⚠️ 這裡的呈現順序很重要。mac 的**主儲存是 Keychain**,`.credentials.json` 只是
|
|
81
|
+
// 可能過時的副本 —— 所以「檔案不存在」在 mac 上不是問題,在 Windows/Linux 上才是。
|
|
82
|
+
// 先前把兩者都印成 [BAD]/[ok],結果出現「憑證檔不存在」緊接著「憑證可讀」這種
|
|
83
|
+
// 自相矛盾的畫面。判定一律以 loadToken() 的結果為準,上面兩行只是事實陳述。
|
|
84
|
+
const cp = credsPath();
|
|
85
|
+
const isMac = platform() === "darwin";
|
|
86
|
+
out.push(info(`憑證主儲存 ${isMac ? "macOS Keychain(檔案只是副本,可能過時)" : "檔案"}`));
|
|
87
|
+
out.push(info(`憑證檔 ${cp} -> ${existsSync(cp) ? `存在 ${statSync(cp).size} bytes` : "不存在"}` +
|
|
88
|
+
(!existsSync(cp) && !isMac ? " <- 這個平台只有檔案這條路,所以是問題" : "")));
|
|
89
|
+
let credOk = false;
|
|
90
|
+
try {
|
|
91
|
+
const t = loadToken();
|
|
92
|
+
credOk = true;
|
|
93
|
+
out.push(ok(`憑證可讀(來源 ${t.source},tier=${t.tier ?? "?"},方案=${t.subscription ?? "?"})`));
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
const err = e;
|
|
97
|
+
out.push(bad(`憑證讀取失敗`));
|
|
98
|
+
for (const line of err.message.split("\n"))
|
|
99
|
+
out.push(` ${line.trim()}`);
|
|
100
|
+
problems.push(err.code === EXIT.TOKEN_EXPIRED
|
|
101
|
+
? "token 過期 —— 在 Claude Code 裡跑 /login"
|
|
102
|
+
: "讀不到憑證(細節見上)。本工具需要**帳號登入**(/login);用 API key / Bedrock / Vertex 時取不到額度資訊");
|
|
103
|
+
}
|
|
104
|
+
// ── session 資料 ──
|
|
105
|
+
const pd = projectsDir();
|
|
106
|
+
out.push(info(`session 目錄 ${pd}`));
|
|
107
|
+
const js = jsonlSummary(pd);
|
|
108
|
+
if (js.files > 0) {
|
|
109
|
+
out.push(ok(`找到 ${js.files} 個 .jsonl,最新 ${js.newest?.toLocaleString() ?? "?"}`));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
out.push(bad(`沒有任何 .jsonl —— 這個目錄裡沒有 Claude Code 的使用紀錄`));
|
|
113
|
+
problems.push(`${pd} 是空的。最常見原因是 Claude Code 實際跑在別的地方(WSL、另一個 Windows 帳號、` +
|
|
114
|
+
`或設了 CLAUDE_CONFIG_DIR),本工具看的目錄和它不是同一個`);
|
|
115
|
+
}
|
|
116
|
+
// hook
|
|
117
|
+
const sp = claudeSettingsPath();
|
|
118
|
+
const settings = readJson(sp);
|
|
119
|
+
out.push(settings
|
|
120
|
+
? ok(`settings.json 可讀(SessionStart 群組 ${settings.hooks?.SessionStart?.length ?? 0} 個)`)
|
|
121
|
+
: info(`settings.json 不存在或無法解析:${sp}`));
|
|
122
|
+
// ── 本工具自己的狀態 ──
|
|
123
|
+
out.push("");
|
|
124
|
+
out.push("claude-usage 狀態");
|
|
125
|
+
out.push(info(`狀態目錄 ${stateDir()}`));
|
|
126
|
+
out.push(info(`設定檔 ${configPath()}`));
|
|
127
|
+
out.push(info(`server ${serverUrl()}`));
|
|
128
|
+
const enrolled = existsSync(devicePath());
|
|
129
|
+
out.push(enrolled ? ok("已註冊(device.json 存在)") : bad("尚未註冊 —— 沒有 device.json"));
|
|
130
|
+
if (!enrolled && credOk) {
|
|
131
|
+
problems.push("憑證正常但尚未註冊 —— 跑 claude-usage install(或 claude-usage enroll)");
|
|
132
|
+
}
|
|
133
|
+
const h = loadHealth();
|
|
134
|
+
if (Object.keys(h).length) {
|
|
135
|
+
const warn = healthWarning(h);
|
|
136
|
+
out.push(warn
|
|
137
|
+
? bad(`採集狀態 UNHEALTHY:${warn}`)
|
|
138
|
+
: ok(`採集狀態正常(上次成功 ${h.last_ok_at ?? "-"})`));
|
|
139
|
+
if (h.last_error)
|
|
140
|
+
out.push(` 上次錯誤: ${h.last_error}`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
out.push(info("尚無 health 紀錄(背景任務還沒跑過)"));
|
|
144
|
+
}
|
|
145
|
+
// ── server 連通性 ──
|
|
146
|
+
try {
|
|
147
|
+
const r = await fetch(serverUrl() + "/v1/health", { signal: AbortSignal.timeout(10_000) });
|
|
148
|
+
out.push(r.ok ? ok(`server 可連線(HTTP ${r.status})`) : bad(`server 回應 HTTP ${r.status}`));
|
|
149
|
+
if (!r.ok)
|
|
150
|
+
problems.push(`server 回應 ${r.status} —— 確認 configure --server 的位址正確`);
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
out.push(bad(`server 連不上:${e.message}`));
|
|
154
|
+
problems.push("連不到 ingest server —— 檢查網路/VPN 與 configure --server 的位址");
|
|
155
|
+
}
|
|
156
|
+
out.push("");
|
|
157
|
+
if (problems.length === 0) {
|
|
158
|
+
out.push("結論:沒有發現問題。");
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
out.push(`結論:${problems.length} 個問題`);
|
|
162
|
+
problems.forEach((p, i) => out.push(` ${i + 1}. ${p}`));
|
|
163
|
+
out.push("");
|
|
164
|
+
out.push("把以上整段貼給管理者,通常一眼就能看出是哪一種。");
|
|
165
|
+
}
|
|
166
|
+
console.log(out.join("\n"));
|
|
167
|
+
return problems.length ? 1 : 0;
|
|
168
|
+
}
|
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.
|
|
3
|
+
"version": "0.3.5",
|
|
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": {
|