ccusage-tracker 0.1.6 → 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.
- package/README.md +103 -6
- package/dist/index.js +657 -100
- 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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
import { join as
|
|
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
|
|
231
|
+
return join3(homedir3(), ".claude", "settings.json");
|
|
43
232
|
}
|
|
44
233
|
function sessionEndScriptPath() {
|
|
45
|
-
return
|
|
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(
|
|
250
|
+
return buildHookCommand(join3(homedir3(), ".config", "ccusage-tracker", "session-start.mjs"));
|
|
62
251
|
}
|
|
63
252
|
function isCcusageTrackerHook(command) {
|
|
64
|
-
return
|
|
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
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
621
|
+
fetchHookScript,
|
|
210
622
|
checkServer: defaultCheckServer,
|
|
211
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
373
|
-
import { spawnSync as
|
|
374
|
-
import { join as
|
|
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 (
|
|
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(
|
|
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 =
|
|
411
|
-
if (
|
|
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,19 +837,27 @@ Hook: ${hookInstalled ? "installed" : "not installed"}`);
|
|
|
416
837
|
} else {
|
|
417
838
|
console.log("Buffer: none");
|
|
418
839
|
}
|
|
419
|
-
const configDir =
|
|
420
|
-
const lastErrorPath =
|
|
421
|
-
if (
|
|
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}`);
|
|
425
846
|
console.log(uploadFailureHint(reason));
|
|
426
847
|
} else {
|
|
427
848
|
console.log(`
|
|
428
|
-
|
|
849
|
+
Upload error: none recorded`);
|
|
850
|
+
}
|
|
851
|
+
const lastUploadPath = join4(configDir, "last-upload.txt");
|
|
852
|
+
let lastUploadTs = null;
|
|
853
|
+
if (existsSync4(lastUploadPath)) {
|
|
854
|
+
const parsed = parseInt(readFileSync3(lastUploadPath, "utf-8").trim(), 10);
|
|
855
|
+
if (!isNaN(parsed))
|
|
856
|
+
lastUploadTs = parsed;
|
|
429
857
|
}
|
|
430
|
-
|
|
431
|
-
|
|
858
|
+
console.log(uploadAgeLine(lastUploadTs, Date.now()));
|
|
859
|
+
const lastFlushPath = join4(configDir, "last-flush.txt");
|
|
860
|
+
if (existsSync4(lastFlushPath)) {
|
|
432
861
|
const ts = parseInt(readFileSync3(lastFlushPath, "utf-8").trim(), 10);
|
|
433
862
|
if (!isNaN(ts)) {
|
|
434
863
|
const ageMin = Math.floor((Date.now() - ts) / 60000);
|
|
@@ -436,7 +865,68 @@ Last upload: no recorded failure`);
|
|
|
436
865
|
}
|
|
437
866
|
}
|
|
438
867
|
const probe = probeCcusage();
|
|
439
|
-
console.log(probe ? `ccusage: installed (${probe})` : "ccusage: not found (install with:
|
|
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)";
|
|
917
|
+
}
|
|
918
|
+
var STALE_UPLOAD_MS = 24 * 60 * 60 * 1000;
|
|
919
|
+
function uploadAgeLine(ts, now) {
|
|
920
|
+
if (ts === null) {
|
|
921
|
+
return "Last successful upload: 尚無成功紀錄(剛安裝屬正常,跑完一次 session 後再看)";
|
|
922
|
+
}
|
|
923
|
+
const age = now - ts;
|
|
924
|
+
const when = new Date(ts).toISOString();
|
|
925
|
+
if (age >= STALE_UPLOAD_MS) {
|
|
926
|
+
const hours = Math.floor(age / 3600000);
|
|
927
|
+
return `Last successful upload: ${when} (${hours} 小時前) — 超過 24 小時,上報可能已停擺`;
|
|
928
|
+
}
|
|
929
|
+
return `Last successful upload: ${when} (${Math.floor(age / 60000)} 分鐘前)`;
|
|
440
930
|
}
|
|
441
931
|
function uploadFailureHint(reason) {
|
|
442
932
|
if (reason.includes("ccusage")) {
|
|
@@ -454,7 +944,7 @@ function uploadFailureHint(reason) {
|
|
|
454
944
|
}
|
|
455
945
|
function probeCcusage() {
|
|
456
946
|
try {
|
|
457
|
-
const r =
|
|
947
|
+
const r = spawnSync3("ccusage --version", { encoding: "utf8", shell: true, timeout: 1e4 });
|
|
458
948
|
if (r.status !== 0 || !r.stdout)
|
|
459
949
|
return null;
|
|
460
950
|
return r.stdout.trim() || "version unknown";
|
|
@@ -463,6 +953,65 @@ function probeCcusage() {
|
|
|
463
953
|
}
|
|
464
954
|
}
|
|
465
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
|
+
|
|
466
1015
|
// src/index.ts
|
|
467
1016
|
var args = process.argv.slice(2);
|
|
468
1017
|
var command = args[0];
|
|
@@ -476,6 +1025,12 @@ switch (command) {
|
|
|
476
1025
|
case "status":
|
|
477
1026
|
await statusCommand();
|
|
478
1027
|
break;
|
|
1028
|
+
case "update":
|
|
1029
|
+
await updateCommand();
|
|
1030
|
+
break;
|
|
1031
|
+
case "sync":
|
|
1032
|
+
await syncCommand(args.slice(1));
|
|
1033
|
+
break;
|
|
479
1034
|
default:
|
|
480
1035
|
console.log(`ccusage-tracker CLI
|
|
481
1036
|
`);
|
|
@@ -483,6 +1038,8 @@ switch (command) {
|
|
|
483
1038
|
`);
|
|
484
1039
|
console.log("Commands:");
|
|
485
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)");
|
|
486
1043
|
console.log(" report View team token usage report");
|
|
487
1044
|
console.log(" status Check current configuration status");
|
|
488
1045
|
process.exit(command ? 1 : 0);
|
package/package.json
CHANGED