ccusage-tracker 0.1.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.
Files changed (3) hide show
  1. package/README.md +41 -0
  2. package/dist/index.js +387 -0
  3. package/package.json +51 -0
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # ccusage-tracker
2
+
3
+ CLI for [ccusage-tracker](https://github.com/ericcai0814/ccusage-tracker) — a self-hosted Claude Code usage tracker for teams.
4
+
5
+ Install Claude Code SessionStart/SessionEnd hooks that report token usage to a self-hosted tracker server. Runs on macOS, Linux, and Windows.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ npx ccusage-tracker setup
11
+ ```
12
+
13
+ You'll be asked for your name, the team's server URL, and a team key (ask your admin).
14
+
15
+ ## Commands
16
+
17
+ ```
18
+ ccusage-tracker setup Install hooks and configure server connection
19
+ ccusage-tracker report View team token usage report
20
+ ccusage-tracker status Check current configuration status
21
+ ```
22
+
23
+ After installation, the binary is also available as `tracker` (if installed globally with `npm i -g ccusage-tracker`).
24
+
25
+ ## What it does
26
+
27
+ `setup` writes a config file to `~/.config/ccusage-tracker/config.json` and adds two hooks to your Claude Code `~/.claude/settings.json`:
28
+
29
+ - **SessionStart** — records the model at session start
30
+ - **SessionEnd** — POSTs token usage (and session metrics in 0.2.1+) to your team's tracker server
31
+
32
+ Hook scripts are downloaded from your tracker server, so they always match the server version.
33
+
34
+ ## Requirements
35
+
36
+ - Node.js 18+
37
+ - A running ccusage-tracker server (see [main repo](https://github.com/ericcai0814/ccusage-tracker) for self-hosting)
38
+
39
+ ## License
40
+
41
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/setup.ts
4
+ import { createInterface } from "node:readline";
5
+
6
+ // src/config.ts
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ function getConfigDir() {
11
+ return join(homedir(), ".config", "ccusage-tracker");
12
+ }
13
+ function getConfigPath() {
14
+ return join(getConfigDir(), "config.json");
15
+ }
16
+ function readConfig() {
17
+ const path = getConfigPath();
18
+ if (!existsSync(path))
19
+ return null;
20
+ try {
21
+ return JSON.parse(readFileSync(path, "utf-8"));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ function writeConfig(config) {
27
+ const dir = getConfigDir();
28
+ if (!existsSync(dir)) {
29
+ mkdirSync(dir, { recursive: true });
30
+ }
31
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + `
32
+ `);
33
+ }
34
+
35
+ // src/hooks.ts
36
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
37
+ import { join as join2 } from "node:path";
38
+ import { homedir as homedir2 } from "node:os";
39
+ function getClaudeSettingsPath() {
40
+ return join2(homedir2(), ".claude", "settings.json");
41
+ }
42
+ function getHookCommand() {
43
+ return "node " + join2(homedir2(), ".config", "ccusage-tracker", "session-end.mjs");
44
+ }
45
+ function getStartHookCommand() {
46
+ return "node " + join2(homedir2(), ".config", "ccusage-tracker", "session-start.mjs");
47
+ }
48
+ function isCcusageTrackerHook(command) {
49
+ return !!command && command.includes("ccusage-tracker");
50
+ }
51
+ function appendHookIfMissing(existing, command) {
52
+ const present = existing.some((m) => m.hooks?.some((h) => isCcusageTrackerHook(h.command)));
53
+ if (present) {
54
+ return { matchers: existing, installed: false };
55
+ }
56
+ const newMatcher = {
57
+ matcher: "",
58
+ hooks: [{ type: "command", command }]
59
+ };
60
+ return { matchers: [...existing, newMatcher], installed: true };
61
+ }
62
+ function applyTrackerHooks(settings) {
63
+ const start = appendHookIfMissing(settings.hooks?.SessionStart ?? [], getStartHookCommand());
64
+ const end = appendHookIfMissing(settings.hooks?.SessionEnd ?? [], getHookCommand());
65
+ const updated = start.installed || end.installed ? {
66
+ ...settings,
67
+ hooks: {
68
+ ...settings.hooks,
69
+ SessionStart: start.matchers,
70
+ SessionEnd: end.matchers
71
+ }
72
+ } : settings;
73
+ return {
74
+ updated,
75
+ sessionStartInstalled: start.installed,
76
+ sessionEndInstalled: end.installed
77
+ };
78
+ }
79
+ function installHook(scripts) {
80
+ const settingsPath = getClaudeSettingsPath();
81
+ let settings = {};
82
+ let backedUp = false;
83
+ if (existsSync2(settingsPath)) {
84
+ const raw = readFileSync2(settingsPath, "utf-8");
85
+ settings = JSON.parse(raw);
86
+ const backupPath = settingsPath + ".backup";
87
+ copyFileSync(settingsPath, backupPath);
88
+ backedUp = true;
89
+ }
90
+ const destDir = join2(homedir2(), ".config", "ccusage-tracker");
91
+ mkdirSync2(destDir, { recursive: true });
92
+ writeFileSync2(join2(destDir, "session-end.mjs"), scripts.sessionEnd);
93
+ writeFileSync2(join2(destDir, "session-start.mjs"), scripts.sessionStart);
94
+ const { updated, sessionStartInstalled, sessionEndInstalled } = applyTrackerHooks(settings);
95
+ if (sessionStartInstalled || sessionEndInstalled) {
96
+ writeFileSync2(settingsPath, JSON.stringify(updated, null, 2) + `
97
+ `);
98
+ }
99
+ return { sessionEndInstalled, sessionStartInstalled, backedUp };
100
+ }
101
+ function isHookInstalled() {
102
+ const settingsPath = getClaudeSettingsPath();
103
+ if (!existsSync2(settingsPath))
104
+ return false;
105
+ try {
106
+ const settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
107
+ return settings.hooks?.SessionEnd?.some((m) => m.hooks?.some((h) => isCcusageTrackerHook(h.command))) ?? false;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ // src/commands/setup.ts
114
+ function defaultPrompt(question) {
115
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
116
+ return new Promise((resolve) => {
117
+ rl.question(question, (answer) => {
118
+ rl.close();
119
+ resolve(answer.trim());
120
+ });
121
+ });
122
+ }
123
+ async function defaultCheckServer(serverUrl) {
124
+ try {
125
+ const res = await fetch(`${serverUrl}/api/health`, { signal: AbortSignal.timeout(5000) });
126
+ const body = await res.json();
127
+ return body.ok === true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+ function defaultCheckCcusage() {
133
+ try {
134
+ Bun.spawnSync(["ccusage", "--version"]);
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+ async function defaultFetchHookScript(serverUrl, scriptName) {
141
+ try {
142
+ const res = await fetch(`${serverUrl}/scripts/${scriptName}`, { signal: AbortSignal.timeout(1e4) });
143
+ if (!res.ok)
144
+ return null;
145
+ return await res.text();
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+ var defaultDeps = {
151
+ prompt: defaultPrompt,
152
+ writeConfig,
153
+ installHook,
154
+ fetchHookScript: defaultFetchHookScript,
155
+ checkServer: defaultCheckServer,
156
+ checkCcusage: defaultCheckCcusage,
157
+ log: (msg) => console.log(msg),
158
+ warn: (msg) => console.warn(msg),
159
+ exit: (code) => process.exit(code)
160
+ };
161
+ async function setupCommand(overrides) {
162
+ const deps = { ...defaultDeps, ...overrides };
163
+ deps.log(`ccusage-tracker setup
164
+ `);
165
+ const name = await deps.prompt("Your name: ");
166
+ if (!name) {
167
+ deps.warn("Name is required.");
168
+ deps.exit(1);
169
+ return;
170
+ }
171
+ const serverUrl = await deps.prompt("Server URL (e.g. https://tracker.example.com): ");
172
+ if (!serverUrl) {
173
+ deps.warn("Server URL is required.");
174
+ deps.exit(1);
175
+ return;
176
+ }
177
+ const teamKey = await deps.prompt("Team Key (ask your admin): ");
178
+ if (!teamKey) {
179
+ deps.warn("Team Key is required.");
180
+ deps.exit(1);
181
+ return;
182
+ }
183
+ const config = {
184
+ server_url: serverUrl.replace(/\/+$/, ""),
185
+ team_key: teamKey,
186
+ member_name: name
187
+ };
188
+ deps.writeConfig(config);
189
+ deps.log(`
190
+ Config saved.`);
191
+ const [sessionEnd, sessionStart] = await Promise.all([
192
+ deps.fetchHookScript(config.server_url, "session-end.mjs"),
193
+ deps.fetchHookScript(config.server_url, "session-start.mjs")
194
+ ]);
195
+ if (sessionEnd && sessionStart) {
196
+ try {
197
+ const { sessionEndInstalled, sessionStartInstalled, backedUp } = deps.installHook({
198
+ sessionEnd,
199
+ sessionStart
200
+ });
201
+ if (sessionEndInstalled || sessionStartInstalled) {
202
+ deps.log("SessionStart + SessionEnd hooks installed." + (backedUp ? " (settings.json backed up)" : ""));
203
+ } else {
204
+ deps.log("SessionStart + SessionEnd hooks already installed.");
205
+ }
206
+ } catch (err) {
207
+ deps.warn("Warning: Could not install hooks automatically. " + err.message);
208
+ }
209
+ } else {
210
+ deps.warn("Warning: Could not download hook scripts from " + config.server_url);
211
+ }
212
+ const serverOk = await deps.checkServer(config.server_url);
213
+ if (serverOk) {
214
+ deps.log("Server is reachable.");
215
+ } else {
216
+ deps.warn("Warning: Server is not reachable at " + config.server_url);
217
+ }
218
+ const hasCcusage = deps.checkCcusage();
219
+ if (hasCcusage) {
220
+ deps.log("ccusage is installed.");
221
+ } else {
222
+ deps.warn("Warning: ccusage not found. Install with: npx ccusage@latest");
223
+ }
224
+ deps.log(`
225
+ Setup complete!`);
226
+ }
227
+
228
+ // src/commands/report.ts
229
+ function padRight(str, len) {
230
+ return str.length >= len ? str : str + " ".repeat(len - str.length);
231
+ }
232
+ function padLeft(str, len) {
233
+ return str.length >= len ? str : " ".repeat(len - str.length) + str;
234
+ }
235
+ function formatTable(data) {
236
+ const lines = [];
237
+ lines.push(`Period: ${data.period} (${data.from} ~ ${data.to})`);
238
+ lines.push(`Total Cost: $${data.total_cost_usd.toFixed(2)} | Total Tokens: ${data.total_tokens.toLocaleString()} | Active Members: ${data.active_members}`);
239
+ lines.push("");
240
+ if (data.members.length === 0) {
241
+ lines.push("No usage data for this period.");
242
+ return lines.join(`
243
+ `);
244
+ }
245
+ const header = [
246
+ padRight("Member", 15),
247
+ padLeft("Input", 12),
248
+ padLeft("Output", 12),
249
+ padLeft("Cache Create", 14),
250
+ padLeft("Cache Read", 12),
251
+ padLeft("Cost", 10)
252
+ ].join(" ");
253
+ const separator = "-".repeat(header.length);
254
+ lines.push(header);
255
+ lines.push(separator);
256
+ for (const m of data.members) {
257
+ lines.push([
258
+ padRight(m.member_name, 15),
259
+ padLeft(m.input_tokens.toLocaleString(), 12),
260
+ padLeft(m.output_tokens.toLocaleString(), 12),
261
+ padLeft(m.cache_creation_tokens.toLocaleString(), 14),
262
+ padLeft(m.cache_read_tokens.toLocaleString(), 12),
263
+ padLeft(`$${m.total_cost_usd.toFixed(2)}`, 10)
264
+ ].join(" "));
265
+ }
266
+ return lines.join(`
267
+ `);
268
+ }
269
+ async function reportCommand(args) {
270
+ const config = readConfig();
271
+ if (!config) {
272
+ console.error("Not configured. Run `tracker setup` first.");
273
+ process.exit(1);
274
+ }
275
+ let period = "month";
276
+ let jsonOutput = false;
277
+ for (let i = 0;i < args.length; i++) {
278
+ if (args[i] === "--period" && args[i + 1]) {
279
+ period = args[i + 1];
280
+ i++;
281
+ }
282
+ if (args[i] === "--json") {
283
+ jsonOutput = true;
284
+ }
285
+ }
286
+ try {
287
+ const res = await fetch(`${config.server_url}/api/report/summary?period=${period}`, {
288
+ headers: { Authorization: `Bearer ${config.team_key}` },
289
+ signal: AbortSignal.timeout(1e4)
290
+ });
291
+ if (!res.ok) {
292
+ const body = await res.json().catch(() => ({}));
293
+ console.error(`Server error: ${res.status}`, body);
294
+ process.exit(1);
295
+ }
296
+ const data = await res.json();
297
+ if (jsonOutput) {
298
+ console.log(JSON.stringify(data, null, 2));
299
+ } else {
300
+ console.log(formatTable(data));
301
+ }
302
+ } catch (err) {
303
+ console.error("Failed to fetch report:", err.message);
304
+ process.exit(1);
305
+ }
306
+ }
307
+
308
+ // src/commands/status.ts
309
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
310
+ import { join as join3, dirname } from "node:path";
311
+ async function statusCommand() {
312
+ const configPath = getConfigPath();
313
+ const config = readConfig();
314
+ console.log(`ccusage-tracker status
315
+ `);
316
+ if (existsSync3(configPath)) {
317
+ console.log(`Config: ${configPath} (exists)`);
318
+ } else {
319
+ console.log(`Config: ${configPath} (not found)`);
320
+ console.log("\nRun `tracker setup` to configure.");
321
+ return;
322
+ }
323
+ if (config) {
324
+ console.log(` Member: ${config.member_name}`);
325
+ console.log(` Server: ${config.server_url}`);
326
+ console.log(` Team Key: ${config.team_key.slice(0, 15)}...`);
327
+ }
328
+ const hookInstalled = isHookInstalled();
329
+ console.log(`
330
+ Hook: ${hookInstalled ? "installed" : "not installed"}`);
331
+ if (config) {
332
+ try {
333
+ const res = await fetch(`${config.server_url}/api/health`, {
334
+ signal: AbortSignal.timeout(5000)
335
+ });
336
+ const body = await res.json();
337
+ if (body.ok) {
338
+ console.log(`Server: reachable (v${body.version || "unknown"})`);
339
+ } else {
340
+ console.log("Server: responded but not healthy");
341
+ }
342
+ } catch {
343
+ console.log("Server: unreachable");
344
+ }
345
+ }
346
+ const bufferPath = join3(dirname(configPath), "buffer.jsonl");
347
+ if (existsSync3(bufferPath)) {
348
+ const content = readFileSync3(bufferPath, "utf-8").trim();
349
+ const lineCount = content ? content.split(`
350
+ `).length : 0;
351
+ console.log(`Buffer: ${lineCount} pending entr${lineCount === 1 ? "y" : "ies"} (${bufferPath})`);
352
+ } else {
353
+ console.log("Buffer: none");
354
+ }
355
+ try {
356
+ const result = Bun.spawnSync(["ccusage", "--version"]);
357
+ const version = new TextDecoder().decode(result.stdout).trim();
358
+ console.log(`ccusage: installed (${version || "version unknown"})`);
359
+ } catch {
360
+ console.log("ccusage: not found (install with: npx ccusage@latest)");
361
+ }
362
+ }
363
+
364
+ // src/index.ts
365
+ var args = process.argv.slice(2);
366
+ var command = args[0];
367
+ switch (command) {
368
+ case "setup":
369
+ await setupCommand();
370
+ break;
371
+ case "report":
372
+ await reportCommand(args.slice(1));
373
+ break;
374
+ case "status":
375
+ await statusCommand();
376
+ break;
377
+ default:
378
+ console.log(`ccusage-tracker CLI
379
+ `);
380
+ console.log(`Usage: tracker <command>
381
+ `);
382
+ console.log("Commands:");
383
+ console.log(" setup Configure hook and server connection");
384
+ console.log(" report View team token usage report");
385
+ console.log(" status Check current configuration status");
386
+ process.exit(command ? 1 : 0);
387
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "ccusage-tracker",
3
+ "version": "0.1.0",
4
+ "description": "CLI for ccusage-tracker — install Claude Code SessionStart/SessionEnd hooks to report token usage to a self-hosted team tracker.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ccusage-tracker": "dist/index.js",
8
+ "tracker": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "bun build src/index.ts --outdir dist --target node",
16
+ "test": "bun test",
17
+ "typecheck": "tsc --noEmit",
18
+ "prepublishOnly": "bun run build"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/ericcai0814/ccusage-tracker.git",
23
+ "directory": "packages/cli"
24
+ },
25
+ "homepage": "https://github.com/ericcai0814/ccusage-tracker#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/ericcai0814/ccusage-tracker/issues"
28
+ },
29
+ "license": "MIT",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "keywords": [
34
+ "claude-code",
35
+ "claude",
36
+ "ccusage",
37
+ "tracker",
38
+ "usage",
39
+ "tokens",
40
+ "cli",
41
+ "hook"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "latest",
48
+ "bun-types": "latest",
49
+ "typescript": "^5.8.0"
50
+ }
51
+ }