ccusage-tracker 0.1.1 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +5 -4
  2. package/dist/index.js +67 -27
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  CLI for [ccusage-tracker](https://github.com/ericcai0814/ccusage-tracker) — a self-hosted Claude Code usage tracker for teams.
4
4
 
5
- Install Claude Code SessionStart/SessionEnd hooks that report token usage to a self-hosted tracker server. Runs on macOS, Linux, and Windows.
5
+ Install Claude Code SessionStart/SessionEnd/Stop hooks that report token usage to a self-hosted tracker server. Runs on macOS, Linux, and Windows.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -24,12 +24,13 @@ After installation, the binary is also available as `tracker` (if installed glob
24
24
 
25
25
  ## What it does
26
26
 
27
- `setup` writes a config file to `~/.config/ccusage-tracker/config.json` and adds two hooks to your Claude Code `~/.claude/settings.json`:
27
+ `setup` writes a config file to `~/.config/ccusage-tracker/config.json` and adds three hooks to your Claude Code `~/.claude/settings.json`:
28
28
 
29
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
30
+ - **Stop** — primary reporting path: POSTs token usage + session metrics after each assistant turn, throttled to once per 5 minutes
31
+ - **SessionEnd** — backup path: same payload at session exit, in case Stop missed the last window
31
32
 
32
- Hook scripts are downloaded from your tracker server, so they always match the server version.
33
+ Hook scripts are downloaded from your tracker server, so they always match the server version. Re-running `setup` migrates old (no `--mode`) commands in-place — no manual cleanup needed.
33
34
 
34
35
  ## Requirements
35
36
 
package/dist/index.js CHANGED
@@ -36,11 +36,18 @@ function writeConfig(config) {
36
36
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
37
37
  import { join as join2 } from "node:path";
38
38
  import { homedir as homedir2 } from "node:os";
39
+ var HOOK_TIMEOUT_SEC = 25;
39
40
  function getClaudeSettingsPath() {
40
41
  return join2(homedir2(), ".claude", "settings.json");
41
42
  }
43
+ function sessionEndScriptPath() {
44
+ return join2(homedir2(), ".config", "ccusage-tracker", "session-end.mjs");
45
+ }
42
46
  function getHookCommand() {
43
- return "node " + join2(homedir2(), ".config", "ccusage-tracker", "session-end.mjs");
47
+ return "node " + sessionEndScriptPath() + " --mode=session-end";
48
+ }
49
+ function getStopHookCommand() {
50
+ return "node " + sessionEndScriptPath() + " --mode=stop";
44
51
  }
45
52
  function getStartHookCommand() {
46
53
  return "node " + join2(homedir2(), ".config", "ccusage-tracker", "session-start.mjs");
@@ -48,32 +55,62 @@ function getStartHookCommand() {
48
55
  function isCcusageTrackerHook(command) {
49
56
  return !!command && command.includes("ccusage-tracker");
50
57
  }
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 };
58
+ function matcherEquivalent(a, b) {
59
+ const norm = (s) => s === "" ? "*" : s;
60
+ return norm(a) === norm(b);
61
+ }
62
+ function upsertHook(existing, command, opts = {}) {
63
+ const newEntry = { type: "command", command };
64
+ if (opts.timeout !== undefined)
65
+ newEntry.timeout = opts.timeout;
66
+ const newMatcher = { matcher: opts.matcher ?? "*", hooks: [newEntry] };
67
+ const filtered = existing.map((m) => ({
68
+ matcher: m.matcher,
69
+ hooks: (m.hooks ?? []).filter((h) => !isCcusageTrackerHook(h.command))
70
+ })).filter((m) => m.hooks.length > 0);
71
+ const next = [...filtered, newMatcher];
72
+ const changed = !sameMatchers(existing, next);
73
+ return { matchers: changed ? next : existing, changed };
74
+ }
75
+ function sameMatchers(a, b) {
76
+ if (a.length !== b.length)
77
+ return false;
78
+ for (let i = 0;i < a.length; i++) {
79
+ if (!matcherEquivalent(a[i].matcher, b[i].matcher))
80
+ return false;
81
+ const ha = a[i].hooks ?? [];
82
+ const hb = b[i].hooks ?? [];
83
+ if (ha.length !== hb.length)
84
+ return false;
85
+ for (let j = 0;j < ha.length; j++) {
86
+ if (ha[j].command !== hb[j].command)
87
+ return false;
88
+ if (ha[j].timeout !== hb[j].timeout)
89
+ return false;
90
+ }
55
91
  }
56
- const newMatcher = {
57
- matcher: "",
58
- hooks: [{ type: "command", command }]
59
- };
60
- return { matchers: [...existing, newMatcher], installed: true };
92
+ return true;
61
93
  }
62
94
  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 ? {
95
+ const start = upsertHook(settings.hooks?.SessionStart ?? [], getStartHookCommand());
96
+ const end = upsertHook(settings.hooks?.SessionEnd ?? [], getHookCommand(), { timeout: HOOK_TIMEOUT_SEC });
97
+ const stop = upsertHook(settings.hooks?.Stop ?? [], getStopHookCommand(), { timeout: HOOK_TIMEOUT_SEC });
98
+ const anyChanged = start.changed || end.changed || stop.changed;
99
+ const updated = anyChanged ? {
66
100
  ...settings,
67
101
  hooks: {
68
102
  ...settings.hooks,
69
103
  SessionStart: start.matchers,
70
- SessionEnd: end.matchers
104
+ SessionEnd: end.matchers,
105
+ Stop: stop.matchers
71
106
  }
72
107
  } : settings;
73
108
  return {
74
109
  updated,
75
- sessionStartInstalled: start.installed,
76
- sessionEndInstalled: end.installed
110
+ sessionStartChanged: start.changed,
111
+ sessionEndChanged: end.changed,
112
+ stopChanged: stop.changed,
113
+ anyChanged
77
114
  };
78
115
  }
79
116
  function installHook(scripts) {
@@ -83,20 +120,21 @@ function installHook(scripts) {
83
120
  if (existsSync2(settingsPath)) {
84
121
  const raw = readFileSync2(settingsPath, "utf-8");
85
122
  settings = JSON.parse(raw);
86
- const backupPath = settingsPath + ".backup";
87
- copyFileSync(settingsPath, backupPath);
88
- backedUp = true;
89
123
  }
90
124
  const destDir = join2(homedir2(), ".config", "ccusage-tracker");
91
125
  mkdirSync2(destDir, { recursive: true });
92
126
  writeFileSync2(join2(destDir, "session-end.mjs"), scripts.sessionEnd);
93
127
  writeFileSync2(join2(destDir, "session-start.mjs"), scripts.sessionStart);
94
- const { updated, sessionStartInstalled, sessionEndInstalled } = applyTrackerHooks(settings);
95
- if (sessionStartInstalled || sessionEndInstalled) {
128
+ const { updated, sessionStartChanged, sessionEndChanged, stopChanged, anyChanged } = applyTrackerHooks(settings);
129
+ if (anyChanged) {
130
+ if (existsSync2(settingsPath)) {
131
+ copyFileSync(settingsPath, settingsPath + ".backup");
132
+ backedUp = true;
133
+ }
96
134
  writeFileSync2(settingsPath, JSON.stringify(updated, null, 2) + `
97
135
  `);
98
136
  }
99
- return { sessionEndInstalled, sessionStartInstalled, backedUp };
137
+ return { sessionEndChanged, sessionStartChanged, stopChanged, backedUp };
100
138
  }
101
139
  function isHookInstalled() {
102
140
  const settingsPath = getClaudeSettingsPath();
@@ -104,7 +142,8 @@ function isHookInstalled() {
104
142
  return false;
105
143
  try {
106
144
  const settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
107
- return settings.hooks?.SessionEnd?.some((m) => m.hooks?.some((h) => isCcusageTrackerHook(h.command))) ?? false;
145
+ const hasIn = (arr) => arr?.some((m) => m.hooks?.some((h) => isCcusageTrackerHook(h.command))) ?? false;
146
+ return hasIn(settings.hooks?.Stop) || hasIn(settings.hooks?.SessionEnd);
108
147
  } catch {
109
148
  return false;
110
149
  }
@@ -210,14 +249,15 @@ Config saved.`);
210
249
  ]);
211
250
  if (sessionEnd && sessionStart) {
212
251
  try {
213
- const { sessionEndInstalled, sessionStartInstalled, backedUp } = deps.installHook({
252
+ const { sessionEndChanged, sessionStartChanged, stopChanged, backedUp } = deps.installHook({
214
253
  sessionEnd,
215
254
  sessionStart
216
255
  });
217
- if (sessionEndInstalled || sessionStartInstalled) {
218
- deps.log("SessionStart + SessionEnd hooks installed." + (backedUp ? " (settings.json backed up)" : ""));
256
+ const anyChanged = sessionEndChanged || sessionStartChanged || stopChanged;
257
+ if (anyChanged) {
258
+ deps.log("SessionStart + SessionEnd + Stop hooks installed/updated." + (backedUp ? " (settings.json backed up)" : ""));
219
259
  } else {
220
- deps.log("SessionStart + SessionEnd hooks already installed.");
260
+ deps.log("SessionStart + SessionEnd + Stop hooks already up to date.");
221
261
  }
222
262
  } catch (err) {
223
263
  deps.warn("Warning: Could not install hooks automatically. " + err.message);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ccusage-tracker",
3
- "version": "0.1.1",
4
- "description": "CLI for ccusage-tracker — install Claude Code SessionStart/SessionEnd hooks to report token usage to a self-hosted team tracker.",
3
+ "version": "0.1.3",
4
+ "description": "CLI for ccusage-tracker — install Claude Code SessionStart/SessionEnd/Stop hooks to report token usage to a self-hosted team tracker.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ccusage-tracker": "dist/index.js",