ccusage-tracker 0.1.7 → 0.2.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 +103 -6
  2. package/dist/index.js +636 -100
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,23 +1,52 @@
1
1
  # ccusage-tracker
2
2
 
3
- CLI for [ccusage-tracker](https://github.com/ericcai0814/ccusage-tracker) a self-hosted Claude Code usage tracker for teams.
3
+ CLI for [ccusage-tracker](https://github.com/ericcai0814/ccusage-tracker), a self-hosted Claude Code and Codex usage tracker for teams.
4
4
 
5
- Install Claude Code SessionStart/SessionEnd/Stop hooks that report token usage to a self-hosted tracker server. Runs on macOS, Linux, and Windows.
5
+ Reports usage from local subscription/OAuth logs. No model API key is needed, and reporting does not upload prompts, responses, or tool results. The tracker team key is a separate server access credential.
6
6
 
7
7
  ## Quick start
8
8
 
9
9
  ```bash
10
- npx ccusage-tracker setup
10
+ npx ccusage-tracker@latest setup
11
11
  ```
12
12
 
13
13
  You'll be asked for your name, the team's server URL, and a team key (ask your admin).
14
14
 
15
+ Setup detects which supported tools exist on this machine and wires automatic reporting for each of them: Claude Code hooks in `~/.claude/settings.json`, Codex hooks in `$CODEX_HOME/hooks.json`. It installs the verified collector when missing, and never modifies `$CODEX_HOME/config.toml`.
16
+
17
+ Codex needs one extra, platform-mandated step: **open Codex and run `/hooks` once to trust the ccusage-tracker hooks.** Codex silently skips hooks it has not been told to trust. Codex can run independently without Claude Code installed.
18
+
19
+ ## Updating an existing installation
20
+
21
+ ```bash
22
+ npx ccusage-tracker@latest update
23
+ ```
24
+
25
+ `update` asks no questions and reuses the existing configuration, running the same detection, hook installation and collector step as `setup`. It preserves the exact config bytes, buffered usage, session data, and unrelated settings/hooks in both files. All required scripts are downloaded and syntax-checked before installation; `settings.json` and `hooks.json` are written in one transaction, so a failure in either leaves both untouched. Changed files are backed up. Repeated updates do not duplicate tracker hooks and leave both files byte-identical.
26
+
27
+ Third-party Codex hook groups keep their exact bytes and their position: the tracker group is only appended at the end of the event array, or replaced in place at its own index. Codex trust keys are built from group indexes, so reordering would invalidate the trust of every hook after the moved one.
28
+
29
+ Missing/invalid configuration, download failures, or installation failures return a nonzero exit status; run `setup` if configuration is missing. When neither Claude Code nor Codex is detected, `update` returns nonzero and tells you to install one first (`setup` still saves the configuration and exits 0).
30
+
31
+ `@latest` runs the latest **published CLI**. It does not refresh server-downloaded hooks by itself: `update` downloads those scripts, and new hook features require the corresponding server deployment. A server returning 404/410 for the optional Codex script keeps the Claude installation working, leaves `$CODEX_HOME/hooks.json` untouched (a hook pointing at a missing script is worse than no hook), and prints a compatibility message. Other failures abort the update.
32
+
33
+ For a global installation, update the CLI package and hooks separately:
34
+
35
+ ```bash
36
+ npm install -g ccusage-tracker@latest
37
+ tracker update
38
+ ```
39
+
40
+ There is no universal `npx update` command.
41
+
15
42
  ## Commands
16
43
 
17
44
  ```
18
45
  ccusage-tracker setup Install hooks and configure server connection
46
+ ccusage-tracker update Refresh scripts/hooks using existing configuration
47
+ ccusage-tracker sync codex Report Codex usage now (manual fallback / debugging)
19
48
  ccusage-tracker report View team token usage report
20
- ccusage-tracker status Check current configuration status
49
+ ccusage-tracker status Check configuration and each source's upload status
21
50
  ```
22
51
 
23
52
  After installation, the binary is also available as `tracker` (if installed globally with `npm i -g ccusage-tracker`).
@@ -30,11 +59,79 @@ After installation, the binary is also available as `tracker` (if installed glob
30
59
  - **Stop** — primary reporting path: POSTs token usage + session metrics after each assistant turn, throttled to once per 5 minutes
31
60
  - **SessionEnd** — backup path: same payload at session exit, in case Stop missed the last window
32
61
 
33
- Hook scripts are downloaded from your tracker server, so they always match the server version. Re-running `setup` migrates old (no `--mode`) commands and unquoted tracker hook paths in-place — no manual cleanup needed.
62
+ For Codex it appends two groups to `$CODEX_HOME/hooks.json` (default `~/.codex/hooks.json`), each a single `node "<path>/codex-sync.mjs" --hook` command with a 45 second timeout and no `matcher` key:
63
+
64
+ - **Stop** — reports after each turn, throttled to once per 5 minutes
65
+ - **SessionEnd** — backup path at session exit
66
+
67
+ The hook process only reads `hook_event_name` from the event on stdin and hands the work to a detached background worker; it never blocks Codex and never forwards payload content. The command string is byte-identical across updates, so refreshing the scripts does not require re-trusting the hooks.
68
+
69
+ Hook scripts come from your configured tracker server. Setup/update migrate old tracker commands and quote paths containing spaces. Existing shell/PowerShell installers remain Claude-only; they do not install Codex reporting.
70
+
71
+ When `settings.json` or `hooks.json` is a symbolic link (dotfiles setups), the CLI writes through to the real file and leaves the link in place; the `.backup` is written next to the real file. A link that dangles or resolves to anything other than a regular file is refused, and the whole transaction is rolled back.
72
+
73
+ ### Collector
74
+
75
+ Setup and update run the same collector step, and only when at least one supported tool is detected. When `ccusage` is missing, the CLI prints and runs `npm install -g ccusage@20.0.20`. That is a global npm install: it downloads the package from the registry and runs its install-time (lifecycle) scripts. If you would rather install the collector yourself, set `CCUSAGE_TRACKER_SKIP_COLLECTOR_INSTALL=1` before running setup or update — the CLI then only prints the command. When a different major version is installed, it warns that Claude reporting works but Codex requires 20.0.20, and does not replace it; the same check applies to the version probed right after an automatic install, since another `ccusage` earlier on PATH may win. A failed install never changes the exit status of setup or update — `status` reports what is missing.
76
+
77
+ ## Codex usage
78
+
79
+ Keep your existing ChatGPT subscription/OAuth login. Make sure `ccusage` is on both your terminal's and Codex's PATH — setup installs it for you when it is missing.
80
+
81
+ ```bash
82
+ # First installation; existing users run update instead.
83
+ npx ccusage-tracker@latest setup
84
+
85
+ # Then, inside Codex, trust the hooks once:
86
+ /hooks
87
+
88
+ npx ccusage-tracker@latest status
89
+ npx ccusage-tracker@latest report --period today --json
90
+ ```
91
+
92
+ ### Trust the hooks once
93
+
94
+ Codex **silently skips** hooks it has not been told to trust, so "installed but never runs" is the most likely way this fails. After setup or update, run `/hooks` inside Codex and trust the ccusage-tracker entries. Codex records that in its own `config.toml`.
95
+
96
+ `status` prints one `Codex hooks:` line:
97
+
98
+ | Output | Meaning |
99
+ |---|---|
100
+ | `installed, trust recorded` | Hooks are installed and a matching trust record exists |
101
+ | `installed, awaiting trust (open /hooks in Codex)` | Installed, but Codex will not run them yet |
102
+ | `installed, disabled in Codex` | You disabled them inside Codex |
103
+ | `not installed` | Not wired yet — run `update` |
104
+
105
+ The CLI only reads `config.toml` to see whether a trust record exists. It never validates the hash (Codex's algorithm is internal, hence "trust recorded" rather than "trusted") and never writes `hooks.state` on your behalf.
106
+
107
+ ### Manual fallback
108
+
109
+ `sync codex` remains available for manual reporting and debugging; normal operation does not depend on it. It waits for the result, returns nonzero on failure, and ignores the five-minute throttle, so it is the way to confirm the final usage right now.
110
+
111
+ ```bash
112
+ npx ccusage-tracker@latest sync codex
113
+ ```
114
+
115
+ Status shows the Codex script/collector, hook trust state, pending buffer, last error, and last successful upload. No readable usage means “no data,” without a fabricated zero snapshot.
116
+
117
+ ### The old notify entry (deprecated)
118
+
119
+ Earlier versions asked you to add `notify = [... "--notify"]` to `$CODEX_HOME/config.toml` by hand. Hooks now handle automatic reporting and that path is kept only for compatibility. If you configured it, remove the tracker entry yourself to avoid triggering twice — setup/update warn when they see it but **never edit the TOML**. Leaving it in place cannot corrupt the data: same-day snapshots are idempotent, the lock is exclusive, and both trigger paths share the same five-minute throttle. Remove the tracker group from `hooks.json` (and the notify entry, if any) before uninstalling tracker; the uninstall script edits neither file.
120
+
121
+ ### Accounting and limits
122
+
123
+ - The verified `ccusage 20.0.20 codex daily --json` output is `{daily, totals}` with `costUSD` and a `models` object. Its input already excludes cache reads. Raw input 1000, cached input 400, output 200, and reasoning 50 become **600 + 400 + 200 = 1200** tokens. Reasoning is already in output; cache creation is zero.
124
+ - Claude retains `daily`; Codex uses `codex-daily`. Each is an idempotent member/date snapshot, so repeats update rather than duplicate usage. Reports add the two sources once. Costs are API-equivalent estimates, not subscription invoices.
125
+ - Verified published collectors are Claude `18.0.9`/`18.0.10` and Claude/Codex `20.0.20`; these are evidence, not an exact patch whitelist. The legacy family (major <=19) retains `daily --json --since`; major 20 requires explicit `claude daily` or `codex daily`, with day/timezone flags and schema validation on every result. Unknown majors (such as 99), unsupported commands, and invalid schemas fail visibly without falling back to unified daily. `20.0.21` is a synthetic compatibility fixture, not a tested published release; untested versions are not guaranteed. The separate `@ccusage/codex 19.0.0`/`ccusage-codex` package is not used.
126
+ - Sync collects the current local day only, with no historical backfill. Keep original compact Codex JSONL: the tested native collector can skip reformatted logs. The server's Today filter uses UTC; use `--period month` or the daily API when local and UTC dates differ.
127
+ - Codex has its own lock, buffer, and error/success markers in `~/.config/ccusage-tracker/`. Failed snapshots remain until delivered; newer same-day snapshots replace stale ones before retry. Claude keeps its separate buffer and five-minute Stop throttle. Codex hook and legacy notify triggers share their own five-minute throttle recorded in `codex-last-flush.txt` and skip overlapping syncs; manual sync ignores the throttle and can confirm the final usage.
128
+ - An interrupted stale-lock recovery can leave `codex-worker.lock.reclaim` or `worker.lock.reclaim`. If the reported recovery error persists, stop the corresponding workers before removing only that marker and retrying.
129
+ - Codex session behavior analytics are outside this feature.
34
130
 
35
131
  ## Requirements
36
132
 
37
- - Node.js 18+
133
+ - Tracker CLI/scripts: Node.js 18+; macOS, Linux, and Windows paths are supported. Runtime verification used Node 24 and Node 18.20.8 on macOS, including paths with spaces; other operating systems were not executed. Node 18 fixtures declare ES modules explicitly, matching the CLI package metadata.
134
+ - Collector: `ccusage@20.0.20` is the verified Codex installation choice, using its platform-specific native package (verified on macOS arm64). Compatible major-20 patches are not rejected solely for their patch number. Claude-only `ccusage@18.0.9`/`18.0.10` require Node >=20.19.4 independently of tracker's engine. The unused standalone `@ccusage/codex@19.0.0` requires Node >=22.
38
135
  - A running ccusage-tracker server (see [main repo](https://github.com/ericcai0814/ccusage-tracker) for self-hosting)
39
136
 
40
137
  ## License
package/dist/index.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  // src/commands/setup.ts
4
4
  import { createInterface } from "node:readline";
5
- import { spawnSync } from "node:child_process";
6
5
 
7
6
  // src/config.ts
8
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -19,11 +18,25 @@ function readConfig() {
19
18
  if (!existsSync(path))
20
19
  return null;
21
20
  try {
22
- return JSON.parse(readFileSync(path, "utf-8"));
21
+ const value = JSON.parse(readFileSync(path, "utf-8"));
22
+ return isTrackerConfig(value) ? value : null;
23
23
  } catch {
24
24
  return null;
25
25
  }
26
26
  }
27
+ function isTrackerConfig(value) {
28
+ if (!value || typeof value !== "object")
29
+ return false;
30
+ const config = value;
31
+ if (![config.server_url, config.team_key, config.member_name].every((field) => typeof field === "string" && field.trim().length > 0))
32
+ return false;
33
+ try {
34
+ const url = new URL(config.server_url);
35
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
27
40
  function writeConfig(config) {
28
41
  const dir = getConfigDir();
29
42
  if (!existsSync(dir)) {
@@ -34,15 +47,191 @@ function writeConfig(config) {
34
47
  }
35
48
 
36
49
  // src/hooks.ts
37
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
38
- import { join as join2 } from "node:path";
50
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, lstatSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync } from "node:fs";
51
+ import { join as join3, dirname } from "node:path";
52
+ import { homedir as homedir3 } from "node:os";
53
+ import { randomUUID } from "node:crypto";
54
+
55
+ // src/scripts.ts
56
+ import { spawnSync } from "node:child_process";
57
+ async function fetchHookScript(serverUrl, scriptName) {
58
+ const response = await fetch(`${serverUrl.replace(/\/+$/, "")}/scripts/${scriptName}`, {
59
+ signal: AbortSignal.timeout(1e4)
60
+ });
61
+ if (scriptName === "codex-sync.mjs" && [404, 410].includes(response.status))
62
+ return null;
63
+ if (!response.ok)
64
+ throw new Error(`Could not download ${scriptName} (HTTP ${response.status}).`);
65
+ return response.text();
66
+ }
67
+ async function downloadScripts(serverUrl, fetchScript = fetchHookScript) {
68
+ const [sessionEnd, sessionStart, codexSync] = await Promise.all([
69
+ fetchScript(serverUrl, "session-end.mjs"),
70
+ fetchScript(serverUrl, "session-start.mjs"),
71
+ fetchScript(serverUrl, "codex-sync.mjs")
72
+ ]);
73
+ if (!sessionEnd?.trim() || !sessionStart?.trim() || codexSync === "") {
74
+ throw new Error("Server returned an empty or missing hook script.");
75
+ }
76
+ return { sessionEnd, sessionStart, ...codexSync === null ? {} : { codexSync } };
77
+ }
78
+ function validateScripts(scripts) {
79
+ for (const [name, source] of Object.entries(scripts)) {
80
+ if (!source.trim())
81
+ throw new Error(`Server returned an empty ${name} script.`);
82
+ const check = spawnSync(process.execPath, ["--check", "--input-type=module"], {
83
+ input: source,
84
+ encoding: "utf8",
85
+ timeout: 1e4
86
+ });
87
+ if (check.status !== 0)
88
+ throw new Error(`Invalid JavaScript in ${name}; installation unchanged.`);
89
+ }
90
+ }
91
+ var CODEX_COMPATIBILITY_MESSAGE = "This server does not provide Codex support (404/410). Claude hooks can still be updated; any existing Codex script is preserved. Deploy a server version with Codex support, then run `tracker update`.";
92
+
93
+ // src/codex-hooks.ts
94
+ import { existsSync as existsSync2 } from "node:fs";
95
+ import { delimiter, join as join2 } from "node:path";
39
96
  import { homedir as homedir2 } from "node:os";
97
+ var CODEX_HOOK_TIMEOUT_SEC = 45;
98
+ var TRUST_EVENT_KEYS = { stop: "stop", sessionEnd: "session_end" };
99
+ var CODEX_EVENTS = { stop: "Stop", sessionEnd: "SessionEnd" };
100
+ function getCodexHome() {
101
+ const configured = process.env.CODEX_HOME?.trim();
102
+ return configured ? configured : join2(homedir2(), ".codex");
103
+ }
104
+ function getCodexHooksPath() {
105
+ return join2(getCodexHome(), "hooks.json");
106
+ }
107
+ function getCodexSyncScriptPath() {
108
+ return join2(homedir2(), ".config", "ccusage-tracker", "codex-sync.mjs");
109
+ }
110
+ function getCodexHookCommand() {
111
+ return `node "${getCodexSyncScriptPath()}" --hook`;
112
+ }
113
+ function isCodexTrackerHook(command) {
114
+ return typeof command === "string" && /[/\\]ccusage-tracker[/\\]codex-sync\.mjs(?=["'\s]|$)/.test(command);
115
+ }
116
+ function isOnPath(name) {
117
+ const entries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
118
+ const suffixes = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
119
+ return entries.some((dir) => suffixes.some((suffix) => existsSync2(join2(dir, name + suffix))));
120
+ }
121
+ function detectCodex() {
122
+ return existsSync2(getCodexHome()) || isOnPath("codex");
123
+ }
124
+ function invalid(detail) {
125
+ throw new Error(`Codex hooks.json ${detail}; repair it before updating.`);
126
+ }
127
+ function readGroups(file, event) {
128
+ const groups = file.hooks?.[event];
129
+ if (groups === undefined)
130
+ return [];
131
+ if (!Array.isArray(groups))
132
+ invalid(`${event} hooks must be an array`);
133
+ for (const group of groups) {
134
+ if (!group || typeof group !== "object" || Array.isArray(group))
135
+ invalid(`${event} hook groups must be objects`);
136
+ if (!Array.isArray(group.hooks) || group.hooks.some((hook) => !hook || typeof hook !== "object" || Array.isArray(hook))) {
137
+ invalid(`${event} hook entries are invalid`);
138
+ }
139
+ }
140
+ return groups;
141
+ }
142
+ function upsertCodexGroups(groups, command) {
143
+ const entry = { type: "command", command, timeout: CODEX_HOOK_TIMEOUT_SEC };
144
+ const index = groups.findIndex((group) => group.hooks.some((hook) => isCodexTrackerHook(hook.command)));
145
+ if (index === -1)
146
+ return { groups: [...groups, { hooks: [entry] }], changed: true };
147
+ const current = groups[index];
148
+ const onlyTracker = current.hooks.every((hook) => isCodexTrackerHook(hook.command));
149
+ const replacement = onlyTracker ? { hooks: [entry] } : { ...current, hooks: current.hooks.map((hook) => isCodexTrackerHook(hook.command) ? entry : hook) };
150
+ if (JSON.stringify(current) === JSON.stringify(replacement))
151
+ return { groups, changed: false };
152
+ return { groups: groups.map((group, at) => at === index ? replacement : group), changed: true };
153
+ }
154
+ function applyCodexHooks(file, command = getCodexHookCommand()) {
155
+ if (!file || typeof file !== "object" || Array.isArray(file))
156
+ invalid("must contain a JSON object");
157
+ if (file.hooks !== undefined && (!file.hooks || typeof file.hooks !== "object" || Array.isArray(file.hooks))) {
158
+ invalid("hooks must be an object");
159
+ }
160
+ const stop = upsertCodexGroups(readGroups(file, CODEX_EVENTS.stop), command);
161
+ const sessionEnd = upsertCodexGroups(readGroups(file, CODEX_EVENTS.sessionEnd), command);
162
+ const anyChanged = stop.changed || sessionEnd.changed;
163
+ return {
164
+ updated: anyChanged ? { ...file, hooks: { ...file.hooks, [CODEX_EVENTS.stop]: stop.groups, [CODEX_EVENTS.sessionEnd]: sessionEnd.groups } } : file,
165
+ stopChanged: stop.changed,
166
+ sessionEndChanged: sessionEnd.changed,
167
+ anyChanged
168
+ };
169
+ }
170
+ function findCodexTrackerIndexes(file) {
171
+ const find = (event) => {
172
+ const groups = file.hooks?.[event];
173
+ if (!Array.isArray(groups))
174
+ return;
175
+ const index = groups.findIndex((group) => Array.isArray(group?.hooks) && group.hooks.some((hook) => isCodexTrackerHook(hook?.command)));
176
+ return index === -1 ? undefined : index;
177
+ };
178
+ const stop = find(CODEX_EVENTS.stop);
179
+ const sessionEnd = find(CODEX_EVENTS.sessionEnd);
180
+ return { ...stop === undefined ? {} : { stop }, ...sessionEnd === undefined ? {} : { sessionEnd } };
181
+ }
182
+ function readCodexTrustState(configToml, hooksPath, indexes) {
183
+ const wanted = new Map;
184
+ for (const event of ["stop", "sessionEnd"]) {
185
+ const index = indexes[event];
186
+ if (index !== undefined)
187
+ wanted.set(`${hooksPath}:${TRUST_EVENT_KEYS[event]}:${index}:0`, event);
188
+ }
189
+ const state = {};
190
+ for (const event of wanted.values())
191
+ state[event] = "awaiting";
192
+ let current = null;
193
+ for (const line of configToml.split(/\r?\n/)) {
194
+ const trimmed = line.trim();
195
+ if (trimmed.startsWith("[")) {
196
+ const header = /^\[hooks\.state\."(.*)"\]$/.exec(trimmed);
197
+ const key = header ? header[1].replace(/\\(["\\])/g, "$1") : null;
198
+ current = key !== null && wanted.has(key) ? wanted.get(key) : null;
199
+ continue;
200
+ }
201
+ if (!current)
202
+ continue;
203
+ if (/^enabled\s*=\s*false\b/.test(trimmed))
204
+ state[current] = "disabled";
205
+ else if (/^trusted_hash\s*=/.test(trimmed) && state[current] !== "disabled")
206
+ state[current] = "recorded";
207
+ }
208
+ return state;
209
+ }
210
+ function hasTrackerNotify(configToml) {
211
+ const lines = configToml.split(/\r?\n/);
212
+ let collected = null;
213
+ let depth = 0;
214
+ for (const line of lines) {
215
+ const trimmed = line.trim();
216
+ if (collected === null && /^\[[^\]]*\]$/.test(trimmed))
217
+ return false;
218
+ if (collected === null && !/^notify\s*=/.test(trimmed))
219
+ continue;
220
+ collected = (collected ?? "") + trimmed;
221
+ depth += (trimmed.match(/\[/g)?.length ?? 0) - (trimmed.match(/\]/g)?.length ?? 0);
222
+ if (depth <= 0)
223
+ break;
224
+ }
225
+ return collected !== null && /[/\\]ccusage-tracker[/\\]codex-sync\.mjs/.test(collected);
226
+ }
227
+
228
+ // src/hooks.ts
40
229
  var HOOK_TIMEOUT_SEC = 45;
41
230
  function getClaudeSettingsPath() {
42
- return join2(homedir2(), ".claude", "settings.json");
231
+ return join3(homedir3(), ".claude", "settings.json");
43
232
  }
44
233
  function sessionEndScriptPath() {
45
- return join2(homedir2(), ".config", "ccusage-tracker", "session-end.mjs");
234
+ return join3(homedir3(), ".config", "ccusage-tracker", "session-end.mjs");
46
235
  }
47
236
  function quoteScriptPath(path) {
48
237
  return `"${path}"`;
@@ -58,10 +247,13 @@ function getStopHookCommand() {
58
247
  return buildHookCommand(sessionEndScriptPath(), "stop");
59
248
  }
60
249
  function getStartHookCommand() {
61
- return buildHookCommand(join2(homedir2(), ".config", "ccusage-tracker", "session-start.mjs"));
250
+ return buildHookCommand(join3(homedir3(), ".config", "ccusage-tracker", "session-start.mjs"));
62
251
  }
63
252
  function isCcusageTrackerHook(command) {
64
- return !!command && command.includes("ccusage-tracker");
253
+ return typeof command === "string" && /[/\\]ccusage-tracker(?:[/\\](?:session-end|session-start|codex-sync)\.(?:mjs|sh|ps1)|\.(?:sh|ps1))(?=["'\s]|$)/.test(command);
254
+ }
255
+ function detectClaude(home = homedir3()) {
256
+ return existsSync3(join3(home, ".claude")) || isOnPath("claude");
65
257
  }
66
258
  function matcherEquivalent(a, b) {
67
259
  const norm = (s) => s === "" ? "*" : s;
@@ -71,11 +263,14 @@ function upsertHook(existing, command, opts = {}) {
71
263
  const newEntry = { type: "command", command };
72
264
  if (opts.timeout !== undefined)
73
265
  newEntry.timeout = opts.timeout;
74
- const newMatcher = { matcher: opts.matcher ?? "*", hooks: [newEntry] };
75
- const filtered = existing.map((m) => ({
76
- matcher: m.matcher,
77
- hooks: (m.hooks ?? []).filter((h) => !isCcusageTrackerHook(h.command))
78
- })).filter((m) => m.hooks.length > 0);
266
+ const previous = existing.find((m) => m.hooks?.length && m.hooks.every((h) => isCcusageTrackerHook(h.command)));
267
+ const newMatcher = { ...previous, matcher: opts.matcher ?? "*", hooks: [newEntry] };
268
+ const filtered = existing.flatMap((m) => {
269
+ if (!m.hooks?.some((h) => isCcusageTrackerHook(h.command)))
270
+ return [m];
271
+ const hooks = m.hooks.filter((h) => !isCcusageTrackerHook(h.command));
272
+ return hooks.length ? [{ ...m, hooks }] : [];
273
+ });
79
274
  const next = [...filtered, newMatcher];
80
275
  const changed = !sameMatchers(existing, next);
81
276
  return { matchers: changed ? next : existing, changed };
@@ -86,16 +281,8 @@ function sameMatchers(a, b) {
86
281
  for (let i = 0;i < a.length; i++) {
87
282
  if (!matcherEquivalent(a[i].matcher, b[i].matcher))
88
283
  return false;
89
- const ha = a[i].hooks ?? [];
90
- const hb = b[i].hooks ?? [];
91
- if (ha.length !== hb.length)
284
+ if (JSON.stringify({ ...a[i], matcher: "*" }) !== JSON.stringify({ ...b[i], matcher: "*" }))
92
285
  return false;
93
- for (let j = 0;j < ha.length; j++) {
94
- if (ha[j].command !== hb[j].command)
95
- return false;
96
- if (ha[j].timeout !== hb[j].timeout)
97
- return false;
98
- }
99
286
  }
100
287
  return true;
101
288
  }
@@ -121,32 +308,210 @@ function applyTrackerHooks(settings) {
121
308
  anyChanged
122
309
  };
123
310
  }
124
- function installHook(scripts) {
311
+ function readCodexHooksFile(path) {
312
+ if (!existsSync3(path))
313
+ return {};
314
+ let parsed;
315
+ try {
316
+ parsed = JSON.parse(readFileSync2(path, "utf-8"));
317
+ } catch {
318
+ throw new Error("Codex hooks.json is not valid JSON; repair it before updating.");
319
+ }
320
+ return parsed;
321
+ }
322
+ function installHook(scripts, targets = { claude: true, codex: false }) {
125
323
  const settingsPath = getClaudeSettingsPath();
126
324
  let settings = {};
127
- let backedUp = false;
128
- if (existsSync2(settingsPath)) {
325
+ if (targets.claude && existsSync3(settingsPath)) {
129
326
  const raw = readFileSync2(settingsPath, "utf-8");
130
327
  settings = JSON.parse(raw);
328
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
329
+ throw new Error("Claude settings.json must contain a JSON object; repair it before updating.");
330
+ }
331
+ if (settings.hooks !== undefined) {
332
+ if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
333
+ throw new Error("Claude settings.json hooks must be an object; repair it before updating.");
334
+ }
335
+ for (const event of ["SessionStart", "SessionEnd", "Stop"]) {
336
+ const matchers = settings.hooks[event];
337
+ if (matchers === undefined)
338
+ continue;
339
+ if (!Array.isArray(matchers) || matchers.some((matcher) => !matcher || !Array.isArray(matcher.hooks) || matcher.hooks.some((hook) => !hook || typeof hook !== "object")))
340
+ throw new Error(`Claude settings.json ${event} hooks are invalid; repair them before updating.`);
341
+ }
342
+ }
131
343
  }
132
- const destDir = join2(homedir2(), ".config", "ccusage-tracker");
133
- mkdirSync2(destDir, { recursive: true });
134
- writeFileSync2(join2(destDir, "session-end.mjs"), scripts.sessionEnd);
135
- writeFileSync2(join2(destDir, "session-start.mjs"), scripts.sessionStart);
136
- const { updated, sessionStartChanged, sessionEndChanged, stopChanged, anyChanged } = applyTrackerHooks(settings);
137
- if (anyChanged) {
138
- if (existsSync2(settingsPath)) {
139
- copyFileSync(settingsPath, settingsPath + ".backup");
140
- backedUp = true;
344
+ validateScripts(scripts);
345
+ const claude = applyTrackerHooks(settings);
346
+ const claudeChanged = targets.claude && claude.anyChanged;
347
+ const codexWired = targets.codex && scripts.codexSync !== undefined;
348
+ const codexHooksPath = getCodexHooksPath();
349
+ const codex = codexWired ? applyCodexHooks(readCodexHooksFile(codexHooksPath)) : { updated: {}, stopChanged: false, sessionEndChanged: false, anyChanged: false };
350
+ const destDir = join3(homedir3(), ".config", "ccusage-tracker");
351
+ const files = [
352
+ { path: join3(destDir, "session-end.mjs"), content: scripts.sessionEnd },
353
+ { path: join3(destDir, "session-start.mjs"), content: scripts.sessionStart },
354
+ ...scripts.codexSync === undefined ? [] : [{ path: join3(destDir, "codex-sync.mjs"), content: scripts.codexSync }],
355
+ ...claudeChanged ? [{ path: settingsPath, content: JSON.stringify(claude.updated, null, 2) + `
356
+ ` }] : [],
357
+ ...codex.anyChanged ? [{ path: codexHooksPath, content: JSON.stringify(codex.updated, null, 2) + `
358
+ ` }] : []
359
+ ];
360
+ const backedUp = installFiles(files);
361
+ return {
362
+ sessionEndChanged: targets.claude && claude.sessionEndChanged,
363
+ sessionStartChanged: targets.claude && claude.sessionStartChanged,
364
+ stopChanged: targets.claude && claude.stopChanged,
365
+ claudeChanged,
366
+ codexStopChanged: codex.stopChanged,
367
+ codexSessionEndChanged: codex.sessionEndChanged,
368
+ codexChanged: codex.anyChanged,
369
+ codexWired,
370
+ codexIndexes: codexWired ? findCodexTrackerIndexes(codex.updated) : {},
371
+ backedUp
372
+ };
373
+ }
374
+ var NO_TOOL_MESSAGE = "No supported tool detected (Claude Code or Codex). Install one, then run `tracker update`.";
375
+ var CODEX_TRUST_MESSAGE = "Codex: hooks installed (Stop, SessionEnd). Open Codex and run /hooks once to trust the ccusage-tracker hooks.";
376
+ var CODEX_DISABLED_MESSAGE = "Codex: hooks installed but disabled in Codex";
377
+ var defaultWiringDeps = {
378
+ detectClaude: () => detectClaude(),
379
+ detectCodex,
380
+ installHook,
381
+ readCodexConfig: () => {
382
+ const path = join3(getCodexHome(), "config.toml");
383
+ try {
384
+ return existsSync3(path) ? readFileSync2(path, "utf-8") : null;
385
+ } catch {
386
+ return null;
141
387
  }
142
- writeFileSync2(settingsPath, JSON.stringify(updated, null, 2) + `
143
- `);
144
388
  }
145
- return { sessionEndChanged, sessionStartChanged, stopChanged, backedUp };
389
+ };
390
+ function wireTools(scripts, deps) {
391
+ const claudeDetected = deps.detectClaude();
392
+ const codexDetected = deps.detectCodex();
393
+ const result = deps.installHook(scripts, { claude: claudeDetected, codex: codexDetected });
394
+ if (!claudeDetected)
395
+ deps.log("Claude Code: not detected");
396
+ else if (result.claudeChanged)
397
+ deps.log("Claude Code: hooks installed/updated (SessionStart, SessionEnd, Stop)");
398
+ else
399
+ deps.log("Claude Code: hooks already up to date");
400
+ if (codexDetected && scripts.codexSync === undefined)
401
+ deps.warn(CODEX_COMPATIBILITY_MESSAGE);
402
+ const configToml = codexDetected ? deps.readCodexConfig() ?? "" : "";
403
+ if (!codexDetected)
404
+ deps.log("Codex: not detected");
405
+ else if (!result.codexWired)
406
+ deps.log("Codex: hooks not installed (this server does not provide the Codex script)");
407
+ else {
408
+ const trust = readCodexTrustState(configToml, getCodexHooksPath(), result.codexIndexes);
409
+ const states = Object.values(trust);
410
+ const recorded = states.length > 0 && states.every((state) => state === "recorded");
411
+ if (states.includes("disabled"))
412
+ deps.log(CODEX_DISABLED_MESSAGE);
413
+ else
414
+ deps.log(!result.codexChanged && recorded ? "Codex: hooks already up to date (trust recorded)" : CODEX_TRUST_MESSAGE);
415
+ }
416
+ if (hasTrackerNotify(configToml)) {
417
+ deps.warn("Codex hooks now handle reporting. Remove the ccusage-tracker `notify` entry from $CODEX_HOME/config.toml to avoid triggering it twice; this tool never edits that file.");
418
+ }
419
+ if (result.backedUp)
420
+ deps.log("Changed files backed up (.backup).");
421
+ if (!claudeDetected && !codexDetected)
422
+ deps.log(NO_TOOL_MESSAGE);
423
+ return { claudeDetected, codexDetected };
424
+ }
425
+ function nonRegularFileError(path, linkTarget) {
426
+ return new Error(`Refusing to replace non-regular file: ${path}` + (linkTarget === undefined ? "" : ` (symlink target: ${linkTarget})`));
427
+ }
428
+ function resolveWriteTarget(path) {
429
+ let link;
430
+ try {
431
+ link = lstatSync(path);
432
+ } catch {
433
+ return path;
434
+ }
435
+ if (!link.isSymbolicLink()) {
436
+ if (!link.isFile())
437
+ throw nonRegularFileError(path);
438
+ return path;
439
+ }
440
+ let real;
441
+ try {
442
+ real = realpathSync(path);
443
+ } catch {
444
+ throw nonRegularFileError(path, readlinkSync(path));
445
+ }
446
+ if (!statSync(real).isFile())
447
+ throw nonRegularFileError(path, real);
448
+ return real;
449
+ }
450
+ function installFiles(files) {
451
+ const staged = [];
452
+ let backedUp = false;
453
+ let committed = false;
454
+ function stage(path, content) {
455
+ const target = resolveWriteTarget(path);
456
+ mkdirSync2(dirname(target), { recursive: true });
457
+ const current = existsSync3(target) ? lstatSync(target) : null;
458
+ const entry = { path: target, staged: `${target}.${randomUUID()}.tmp`, installed: false };
459
+ staged.push(entry);
460
+ writeFileSync2(entry.staged, content, { flag: "wx", mode: current?.mode ?? 384 });
461
+ if (current) {
462
+ entry.rollback = `${target}.${randomUUID()}.rollback`;
463
+ writeFileSync2(entry.rollback, readFileSync2(target), { flag: "wx", mode: current.mode });
464
+ }
465
+ }
466
+ try {
467
+ for (const file of files) {
468
+ const next = Buffer.from(file.content);
469
+ const target = resolveWriteTarget(file.path);
470
+ if (existsSync3(target)) {
471
+ const previous = readFileSync2(target);
472
+ if (previous.equals(next))
473
+ continue;
474
+ stage(target + ".backup", previous);
475
+ backedUp = true;
476
+ }
477
+ stage(target, next);
478
+ }
479
+ for (const entry of staged) {
480
+ renameSync(entry.staged, entry.path);
481
+ entry.installed = true;
482
+ }
483
+ committed = true;
484
+ } catch (error) {
485
+ const failures = [];
486
+ for (const entry of [...staged].reverse()) {
487
+ if (!entry.installed)
488
+ continue;
489
+ try {
490
+ if (entry.rollback)
491
+ renameSync(entry.rollback, entry.path);
492
+ else
493
+ unlinkSync(entry.path);
494
+ } catch {
495
+ failures.push(entry.path);
496
+ }
497
+ }
498
+ if (failures.length)
499
+ throw new Error(`${error.message}; restore failed for ${failures.join(", ")}. Retained .rollback files require manual recovery.`);
500
+ throw error;
501
+ } finally {
502
+ for (const entry of staged) {
503
+ if (existsSync3(entry.staged))
504
+ unlinkSync(entry.staged);
505
+ if (entry.rollback && existsSync3(entry.rollback) && (committed || !entry.installed)) {
506
+ unlinkSync(entry.rollback);
507
+ }
508
+ }
509
+ }
510
+ return backedUp;
146
511
  }
147
512
  function isHookInstalled() {
148
513
  const settingsPath = getClaudeSettingsPath();
149
- if (!existsSync2(settingsPath))
514
+ if (!existsSync3(settingsPath))
150
515
  return false;
151
516
  try {
152
517
  const settings = JSON.parse(readFileSync2(settingsPath, "utf-8"));
@@ -157,6 +522,70 @@ function isHookInstalled() {
157
522
  }
158
523
  }
159
524
 
525
+ // src/collector.ts
526
+ import { spawnSync as spawnSync2 } from "node:child_process";
527
+ var COLLECTOR_VERSION = "20.0.20";
528
+ var COLLECTOR_INSTALL_COMMAND = `npm install -g ccusage@${COLLECTOR_VERSION}`;
529
+ var SKIP_ENV = "CCUSAGE_TRACKER_SKIP_COLLECTOR_INSTALL";
530
+ var INSTALL_TIMEOUT_MS = 180000;
531
+ function defaultProbe() {
532
+ try {
533
+ const result = spawnSync2("ccusage --version", { encoding: "utf8", shell: true, timeout: 1e4 });
534
+ if (result.status !== 0 || !result.stdout)
535
+ return null;
536
+ return result.stdout.trim() || null;
537
+ } catch {
538
+ return null;
539
+ }
540
+ }
541
+ function defaultInstall(command) {
542
+ try {
543
+ return spawnSync2(command, { shell: true, stdio: "inherit", timeout: INSTALL_TIMEOUT_MS }).status === 0;
544
+ } catch {
545
+ return false;
546
+ }
547
+ }
548
+ var defaultCollectorDeps = {
549
+ probe: defaultProbe,
550
+ install: defaultInstall
551
+ };
552
+ function displayVersion(version) {
553
+ return version.trim().replace(/^ccusage\s+/, "");
554
+ }
555
+ function majorVersion(version) {
556
+ const match = /^(?:ccusage\s+)?(\d+)\.\d+\.\d+/.exec(version.trim());
557
+ return match ? match[1] : null;
558
+ }
559
+ function unsupportedMajor(deps, version) {
560
+ deps.warn(`Collector: ccusage ${displayVersion(version)} reports usage for Claude, but Codex requires ${COLLECTOR_VERSION}. Install it yourself with: ${COLLECTOR_INSTALL_COMMAND}`);
561
+ return { status: "unsupported_major", version };
562
+ }
563
+ function ensureCollector(deps) {
564
+ const version = deps.probe();
565
+ if (version !== null) {
566
+ if (majorVersion(version) === "20") {
567
+ deps.log(`Collector: ccusage ${displayVersion(version)} (Claude and Codex ready)`);
568
+ return { status: "ok", version };
569
+ }
570
+ return unsupportedMajor(deps, version);
571
+ }
572
+ if (process.env[SKIP_ENV] === "1") {
573
+ deps.log(`Collector: not installed; skipping automatic install (${SKIP_ENV}=1). Install it with: ${COLLECTOR_INSTALL_COMMAND}`);
574
+ return { status: "skipped" };
575
+ }
576
+ deps.log(`Collector: ccusage not found. Running: ${COLLECTOR_INSTALL_COMMAND}`);
577
+ deps.install(COLLECTOR_INSTALL_COMMAND);
578
+ const reprobed = deps.probe();
579
+ if (reprobed === null) {
580
+ deps.warn(`Collector: automatic install did not succeed. Install it yourself with: ${COLLECTOR_INSTALL_COMMAND}`);
581
+ return { status: "install_failed" };
582
+ }
583
+ if (majorVersion(reprobed) !== "20")
584
+ return unsupportedMajor(deps, reprobed);
585
+ deps.log(`Collector: ccusage ${displayVersion(reprobed)} installed.`);
586
+ return { status: "installed", version: reprobed };
587
+ }
588
+
160
589
  // src/commands/setup.ts
161
590
  var sharedRl = null;
162
591
  var lineIterator = null;
@@ -185,30 +614,17 @@ async function defaultCheckServer(serverUrl) {
185
614
  return false;
186
615
  }
187
616
  }
188
- function defaultCheckCcusage() {
189
- try {
190
- return spawnSync("ccusage --version", { encoding: "utf8", shell: true, timeout: 1e4 }).status === 0;
191
- } catch {
192
- return false;
193
- }
194
- }
195
- async function defaultFetchHookScript(serverUrl, scriptName) {
196
- try {
197
- const res = await fetch(`${serverUrl}/scripts/${scriptName}`, { signal: AbortSignal.timeout(1e4) });
198
- if (!res.ok)
199
- return null;
200
- return await res.text();
201
- } catch {
202
- return null;
203
- }
204
- }
205
617
  var defaultDeps = {
206
618
  prompt: defaultPrompt,
207
619
  writeConfig,
208
620
  installHook,
209
- fetchHookScript: defaultFetchHookScript,
621
+ fetchHookScript,
210
622
  checkServer: defaultCheckServer,
211
- checkCcusage: defaultCheckCcusage,
623
+ detectClaude: () => detectClaude(),
624
+ detectCodex,
625
+ probeCollector: defaultCollectorDeps.probe,
626
+ installCollector: defaultCollectorDeps.install,
627
+ readCodexConfig: defaultWiringDeps.readCodexConfig,
212
628
  log: (msg) => console.log(msg),
213
629
  warn: (msg) => console.warn(msg),
214
630
  exit: (code) => process.exit(code)
@@ -247,30 +663,37 @@ async function runSetup(deps) {
247
663
  team_key: teamKey,
248
664
  member_name: name
249
665
  };
666
+ if (!isTrackerConfig(config)) {
667
+ deps.warn("Invalid configuration. Server URL must use http or https without embedded credentials.");
668
+ deps.exit(1);
669
+ return;
670
+ }
250
671
  deps.writeConfig(config);
251
672
  deps.log(`
252
673
  Config saved.`);
253
- const [sessionEnd, sessionStart] = await Promise.all([
254
- deps.fetchHookScript(config.server_url, "session-end.mjs"),
255
- deps.fetchHookScript(config.server_url, "session-start.mjs")
256
- ]);
257
- if (sessionEnd && sessionStart) {
258
- try {
259
- const { sessionEndChanged, sessionStartChanged, stopChanged, backedUp } = deps.installHook({
260
- sessionEnd,
261
- sessionStart
262
- });
263
- const anyChanged = sessionEndChanged || sessionStartChanged || stopChanged;
264
- if (anyChanged) {
265
- deps.log("SessionStart + SessionEnd + Stop hooks installed/updated." + (backedUp ? " (settings.json backed up)" : ""));
266
- } else {
267
- deps.log("SessionStart + SessionEnd + Stop hooks already up to date.");
268
- }
269
- } catch (err) {
270
- deps.warn("Warning: Could not install hooks automatically. " + err.message);
271
- }
272
- } else {
273
- deps.warn("Warning: Could not download hook scripts from " + config.server_url);
674
+ let detected;
675
+ try {
676
+ const scripts = await downloadScripts(config.server_url, deps.fetchHookScript);
677
+ detected = wireTools(scripts, {
678
+ detectClaude: deps.detectClaude,
679
+ detectCodex: deps.detectCodex,
680
+ installHook: deps.installHook,
681
+ readCodexConfig: deps.readCodexConfig,
682
+ log: deps.log,
683
+ warn: deps.warn
684
+ });
685
+ } catch (err) {
686
+ deps.warn("Could not install tracker scripts. " + err.message + " Run `tracker update` after resolving the problem.");
687
+ deps.exit(1);
688
+ return;
689
+ }
690
+ if (detected.claudeDetected || detected.codexDetected) {
691
+ ensureCollector({
692
+ probe: deps.probeCollector,
693
+ install: deps.installCollector,
694
+ log: deps.log,
695
+ warn: deps.warn
696
+ });
274
697
  }
275
698
  const serverOk = await deps.checkServer(config.server_url);
276
699
  if (serverOk) {
@@ -278,12 +701,6 @@ Config saved.`);
278
701
  } else {
279
702
  deps.warn("Warning: Server is not reachable at " + config.server_url);
280
703
  }
281
- const hasCcusage = deps.checkCcusage();
282
- if (hasCcusage) {
283
- deps.log("ccusage is installed.");
284
- } else {
285
- deps.warn("Warning: ccusage not found. Install with: npx ccusage@latest");
286
- }
287
704
  deps.log(`
288
705
  Setup complete!`);
289
706
  }
@@ -369,15 +786,15 @@ async function reportCommand(args) {
369
786
  }
370
787
 
371
788
  // src/commands/status.ts
372
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
373
- import { spawnSync as spawnSync2 } from "node:child_process";
374
- import { join as join3, dirname } from "node:path";
789
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "node:fs";
790
+ import { spawnSync as spawnSync3 } from "node:child_process";
791
+ import { join as join4, dirname as dirname2 } from "node:path";
375
792
  async function statusCommand() {
376
793
  const configPath = getConfigPath();
377
794
  const config = readConfig();
378
795
  console.log(`ccusage-tracker status
379
796
  `);
380
- if (existsSync3(configPath)) {
797
+ if (existsSync4(configPath)) {
381
798
  console.log(`Config: ${configPath} (exists)`);
382
799
  } else {
383
800
  console.log(`Config: ${configPath} (not found)`);
@@ -387,7 +804,11 @@ async function statusCommand() {
387
804
  if (config) {
388
805
  console.log(` Member: ${config.member_name}`);
389
806
  console.log(` Server: ${config.server_url}`);
390
- console.log(` Team Key: ${config.team_key.slice(0, 15)}...`);
807
+ console.log(" Team Key: configured");
808
+ } else {
809
+ console.log("Configuration is invalid. Run `tracker setup` to repair it.");
810
+ process.exitCode = 1;
811
+ return;
391
812
  }
392
813
  const hookInstalled = isHookInstalled();
393
814
  console.log(`
@@ -407,8 +828,8 @@ Hook: ${hookInstalled ? "installed" : "not installed"}`);
407
828
  console.log("Server: unreachable");
408
829
  }
409
830
  }
410
- const bufferPath = join3(dirname(configPath), "buffer.jsonl");
411
- if (existsSync3(bufferPath)) {
831
+ const bufferPath = join4(dirname2(configPath), "buffer.jsonl");
832
+ if (existsSync4(bufferPath)) {
412
833
  const content = readFileSync3(bufferPath, "utf-8").trim();
413
834
  const lineCount = content ? content.split(`
414
835
  `).length : 0;
@@ -416,9 +837,9 @@ Hook: ${hookInstalled ? "installed" : "not installed"}`);
416
837
  } else {
417
838
  console.log("Buffer: none");
418
839
  }
419
- const configDir = dirname(configPath);
420
- const lastErrorPath = join3(configDir, "last-error.txt");
421
- if (existsSync3(lastErrorPath)) {
840
+ const configDir = dirname2(configPath);
841
+ const lastErrorPath = join4(configDir, "last-error.txt");
842
+ if (existsSync4(lastErrorPath)) {
422
843
  const reason = readFileSync3(lastErrorPath, "utf-8").trim();
423
844
  console.log(`
424
845
  Upload error: ${reason}`);
@@ -427,16 +848,16 @@ Upload error: ${reason}`);
427
848
  console.log(`
428
849
  Upload error: none recorded`);
429
850
  }
430
- const lastUploadPath = join3(configDir, "last-upload.txt");
851
+ const lastUploadPath = join4(configDir, "last-upload.txt");
431
852
  let lastUploadTs = null;
432
- if (existsSync3(lastUploadPath)) {
853
+ if (existsSync4(lastUploadPath)) {
433
854
  const parsed = parseInt(readFileSync3(lastUploadPath, "utf-8").trim(), 10);
434
855
  if (!isNaN(parsed))
435
856
  lastUploadTs = parsed;
436
857
  }
437
858
  console.log(uploadAgeLine(lastUploadTs, Date.now()));
438
- const lastFlushPath = join3(configDir, "last-flush.txt");
439
- if (existsSync3(lastFlushPath)) {
859
+ const lastFlushPath = join4(configDir, "last-flush.txt");
860
+ if (existsSync4(lastFlushPath)) {
440
861
  const ts = parseInt(readFileSync3(lastFlushPath, "utf-8").trim(), 10);
441
862
  if (!isNaN(ts)) {
442
863
  const ageMin = Math.floor((Date.now() - ts) / 60000);
@@ -444,7 +865,55 @@ Upload error: none recorded`);
444
865
  }
445
866
  }
446
867
  const probe = probeCcusage();
447
- console.log(probe ? `ccusage: installed (${probe})` : "ccusage: not found (install with: npx ccusage@latest)");
868
+ console.log(probe ? `ccusage: installed (${probe})` : "ccusage: not found (install with: npm install -g ccusage@20.0.20)");
869
+ if (!detectCodex()) {
870
+ console.log(`
871
+ Codex: not detected`);
872
+ return;
873
+ }
874
+ const codexScript = join4(configDir, "codex-sync.mjs");
875
+ console.log(`
876
+ Codex script: ${existsSync4(codexScript) ? "installed" : "not installed (run tracker update with a Codex-capable server)"}`);
877
+ const hooksPath = getCodexHooksPath();
878
+ console.log(codexHooksLine(readTextFile(hooksPath), readTextFile(join4(dirname2(hooksPath), "config.toml")), hooksPath));
879
+ console.log(`Codex Node runtime: ${process.versions.node}`);
880
+ const hasCodexCollector = probe !== null && /^(?:ccusage\s+)?20\.0\.20$/.test(probe);
881
+ console.log(hasCodexCollector ? "Codex collector: installed (ccusage 20.0.20)" : `Codex collector: unavailable (${probe ? `unsupported version: ${probe}` : "ccusage missing"}; install with: npm install -g ccusage@20.0.20)`);
882
+ const codexBufferPath = join4(configDir, "codex-buffer.jsonl");
883
+ const codexLines = existsSync4(codexBufferPath) ? readFileSync3(codexBufferPath, "utf8").trim().split(`
884
+ `).filter(Boolean).length : 0;
885
+ console.log(`Codex buffer: ${codexLines} pending entr${codexLines === 1 ? "y" : "ies"}`);
886
+ const codexErrorPath = join4(configDir, "codex-last-error.txt");
887
+ console.log(`Codex upload error: ${existsSync4(codexErrorPath) ? readFileSync3(codexErrorPath, "utf8").trim() : "none recorded"}`);
888
+ const codexUploadPath = join4(configDir, "codex-last-upload.txt");
889
+ const codexUpload = existsSync4(codexUploadPath) ? Number(readFileSync3(codexUploadPath, "utf8").trim()) : NaN;
890
+ console.log(`Codex last successful upload: ${Number.isFinite(codexUpload) && codexUpload > 0 && !Number.isNaN(new Date(codexUpload).getTime()) ? new Date(codexUpload).toISOString() : "none recorded (run tracker sync codex)"}`);
891
+ }
892
+ function readTextFile(path) {
893
+ try {
894
+ return existsSync4(path) ? readFileSync3(path, "utf8") : null;
895
+ } catch {
896
+ return null;
897
+ }
898
+ }
899
+ function codexHooksLine(hooksJson, configToml, hooksPath) {
900
+ let file = null;
901
+ if (hooksJson !== null) {
902
+ try {
903
+ const parsed = JSON.parse(hooksJson);
904
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
905
+ file = parsed;
906
+ } catch {}
907
+ }
908
+ const indexes = file ? findCodexTrackerIndexes(file) : {};
909
+ if (indexes.stop === undefined && indexes.sessionEnd === undefined)
910
+ return "Codex hooks: not installed";
911
+ const states = Object.values(readCodexTrustState(configToml ?? "", hooksPath, indexes));
912
+ if (states.includes("disabled"))
913
+ return "Codex hooks: installed, disabled in Codex";
914
+ if (states.every((state) => state === "recorded"))
915
+ return "Codex hooks: installed, trust recorded";
916
+ return "Codex hooks: installed, awaiting trust (open /hooks in Codex)";
448
917
  }
449
918
  var STALE_UPLOAD_MS = 24 * 60 * 60 * 1000;
450
919
  function uploadAgeLine(ts, now) {
@@ -475,7 +944,7 @@ function uploadFailureHint(reason) {
475
944
  }
476
945
  function probeCcusage() {
477
946
  try {
478
- const r = spawnSync2("ccusage --version", { encoding: "utf8", shell: true, timeout: 1e4 });
947
+ const r = spawnSync3("ccusage --version", { encoding: "utf8", shell: true, timeout: 1e4 });
479
948
  if (r.status !== 0 || !r.stdout)
480
949
  return null;
481
950
  return r.stdout.trim() || "version unknown";
@@ -484,6 +953,65 @@ function probeCcusage() {
484
953
  }
485
954
  }
486
955
 
956
+ // src/commands/update.ts
957
+ async function updateCommand() {
958
+ const config = readConfig();
959
+ if (!config) {
960
+ console.error("Missing or invalid configuration. Run `tracker setup` first.");
961
+ process.exitCode = 1;
962
+ return;
963
+ }
964
+ const log = (msg) => console.log(msg);
965
+ const warn = (msg) => console.warn(msg);
966
+ let detected;
967
+ try {
968
+ const scripts = await downloadScripts(config.server_url);
969
+ detected = wireTools(scripts, { ...defaultWiringDeps, installHook, log, warn });
970
+ } catch (error) {
971
+ console.error("Update failed: " + error.message);
972
+ process.exitCode = 1;
973
+ return;
974
+ }
975
+ if (!detected.claudeDetected && !detected.codexDetected) {
976
+ process.exitCode = 1;
977
+ return;
978
+ }
979
+ ensureCollector({ probe: defaultCollectorDeps.probe, install: defaultCollectorDeps.install, log, warn });
980
+ console.log(`
981
+ Update complete. Tracker scripts and hooks updated.`);
982
+ }
983
+
984
+ // src/commands/sync.ts
985
+ import { spawn } from "node:child_process";
986
+ import { existsSync as existsSync5 } from "node:fs";
987
+ import { join as join5 } from "node:path";
988
+ async function syncCommand(args) {
989
+ if (args.length !== 1 || args[0] !== "codex") {
990
+ console.error("Usage: tracker sync codex");
991
+ process.exitCode = 1;
992
+ return;
993
+ }
994
+ if (!readConfig()) {
995
+ console.error("Missing or invalid configuration. Run `tracker setup` first.");
996
+ process.exitCode = 1;
997
+ return;
998
+ }
999
+ const script = join5(getConfigDir(), "codex-sync.mjs");
1000
+ if (!existsSync5(script)) {
1001
+ console.error("Codex script not installed. Run `tracker update` with a server that supports Codex.");
1002
+ process.exitCode = 1;
1003
+ return;
1004
+ }
1005
+ process.exitCode = await new Promise((resolve) => {
1006
+ const child = spawn(process.execPath, [script], { stdio: "inherit" });
1007
+ child.on("error", (error) => {
1008
+ console.error("Could not run Codex sync: " + error.message);
1009
+ resolve(1);
1010
+ });
1011
+ child.on("close", (code) => resolve(code ?? 1));
1012
+ });
1013
+ }
1014
+
487
1015
  // src/index.ts
488
1016
  var args = process.argv.slice(2);
489
1017
  var command = args[0];
@@ -497,6 +1025,12 @@ switch (command) {
497
1025
  case "status":
498
1026
  await statusCommand();
499
1027
  break;
1028
+ case "update":
1029
+ await updateCommand();
1030
+ break;
1031
+ case "sync":
1032
+ await syncCommand(args.slice(1));
1033
+ break;
500
1034
  default:
501
1035
  console.log(`ccusage-tracker CLI
502
1036
  `);
@@ -504,6 +1038,8 @@ switch (command) {
504
1038
  `);
505
1039
  console.log("Commands:");
506
1040
  console.log(" setup Configure hook and server connection");
1041
+ console.log(" update Refresh installed scripts/hooks using existing configuration");
1042
+ console.log(" sync codex Report Codex usage now (manual fallback / debugging)");
507
1043
  console.log(" report View team token usage report");
508
1044
  console.log(" status Check current configuration status");
509
1045
  process.exit(command ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccusage-tracker",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
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": {