@ugurcandede/cc-cost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/setup.js ADDED
@@ -0,0 +1,226 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { claudeDirs } from "./paths.js";
6
+ const HOME = os.homedir();
7
+ export const SCHEDULE_TIME = '10:23';
8
+ const [HOUR, MINUTE] = SCHEDULE_TIME.split(':').map(Number);
9
+ // Overridable so tests never touch a real task of the same name
10
+ const TASK = process.env.CC_COST_TASK || 'cc-cost';
11
+ const LAUNCHD_LABEL = 'com.cc-cost.sync';
12
+ export const currentRunner = () => ({ node: process.execPath, script: fs.realpathSync(process.argv[1]) });
13
+ // npx, yarn dlx and pnpm dlx run from temporary or cache folders that get cleaned; a scheduled path
14
+ // there would break silently.
15
+ export const isEphemeral = (r) => /[\\/](_npx|dlx(-\d+)?)[\\/]/.test(r.script);
16
+ // ---------- shared folder
17
+ function exists(p) {
18
+ try {
19
+ return fs.statSync(p).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ // Sync-service folders on this machine, most likely first
26
+ export function syncFolders() {
27
+ const found = [];
28
+ // Dropbox records its folders (personal and business) in info.json
29
+ const dropboxInfo = [
30
+ path.join(process.env.LOCALAPPDATA ?? '', 'Dropbox', 'info.json'),
31
+ path.join(process.env.APPDATA ?? '', 'Dropbox', 'info.json'),
32
+ path.join(HOME, '.dropbox', 'info.json'),
33
+ ];
34
+ for (const f of dropboxInfo) {
35
+ try {
36
+ for (const v of Object.values(JSON.parse(fs.readFileSync(f, 'utf8'))))
37
+ if (v?.path)
38
+ found.push(v.path);
39
+ }
40
+ catch {
41
+ // no Dropbox, or not at this location
42
+ }
43
+ }
44
+ found.push(path.join(HOME, 'Dropbox'));
45
+ for (const v of [process.env.OneDrive, process.env.OneDriveConsumer, process.env.OneDriveCommercial])
46
+ if (v)
47
+ found.push(v);
48
+ found.push(path.join(HOME, 'OneDrive'));
49
+ if (process.platform === 'darwin') {
50
+ found.push(path.join(HOME, 'Library', 'Mobile Documents', 'com~apple~CloudDocs'));
51
+ try {
52
+ for (const e of fs.readdirSync(path.join(HOME, 'Library', 'CloudStorage')))
53
+ found.push(path.join(HOME, 'Library', 'CloudStorage', e, e.startsWith('GoogleDrive-') ? 'My Drive' : ''));
54
+ }
55
+ catch {
56
+ // no File Provider mounts
57
+ }
58
+ }
59
+ if (process.platform === 'win32')
60
+ found.push(path.join(HOME, 'iCloudDrive'), 'G:\\My Drive');
61
+ found.push(path.join(HOME, 'Google Drive'));
62
+ return [...new Set(found.map((p) => path.resolve(p)))].filter(exists);
63
+ }
64
+ // Where to keep the data inside a sync folder: reuse an existing cc-cost folder (including one
65
+ // made by the original script, "claude-cost"), otherwise a new "cc-cost" folder.
66
+ export function dataFolderIn(root) {
67
+ for (const name of ['cc-cost', 'claude-cost'])
68
+ if (exists(path.join(root, name)))
69
+ return path.join(root, name);
70
+ return path.join(root, 'cc-cost');
71
+ }
72
+ // ---------- scheduler
73
+ const psQuote = (s) => `'${s.replace(/'/g, "''")}'`;
74
+ const xml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
75
+ const run = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
76
+ const powershell = (script) => run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
77
+ const launchdPlist = () => path.join(HOME, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
78
+ const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(HOME, '.config'), 'systemd', 'user');
79
+ export function plistFor(r) {
80
+ const args = [r.node, r.script, 'sync', '--quiet'].map((a) => ` <string>${xml(a)}</string>`).join('\n');
81
+ return `<?xml version="1.0" encoding="UTF-8"?>
82
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
83
+ <plist version="1.0">
84
+ <dict>
85
+ <key>Label</key>
86
+ <string>${LAUNCHD_LABEL}</string>
87
+ <key>ProgramArguments</key>
88
+ <array>
89
+ ${args}
90
+ </array>
91
+ <key>StartCalendarInterval</key>
92
+ <dict>
93
+ <key>Hour</key>
94
+ <integer>${HOUR}</integer>
95
+ <key>Minute</key>
96
+ <integer>${MINUTE}</integer>
97
+ </dict>
98
+ </dict>
99
+ </plist>
100
+ `;
101
+ }
102
+ export function systemdUnits(r) {
103
+ return {
104
+ service: `[Unit]\nDescription=cc-cost sync\n\n[Service]\nType=oneshot\nExecStart="${r.node}" "${r.script}" sync --quiet\n`,
105
+ // Persistent: a run missed while the machine was off happens at the next boot
106
+ timer: `[Unit]\nDescription=Daily cc-cost sync\n\n[Timer]\nOnCalendar=*-*-* ${SCHEDULE_TIME}:00\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`,
107
+ };
108
+ }
109
+ // PowerShell that registers the Windows task. The task starts a hidden PowerShell that runs node,
110
+ // so no console window flashes; StartWhenAvailable catches up on a run missed while powered off.
111
+ export function windowsTaskScript(r) {
112
+ const argument = `-NoProfile -WindowStyle Hidden -Command "& ${psQuote(r.node)} ${psQuote(r.script)} sync --quiet"`;
113
+ return [
114
+ `$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${psQuote(argument)}`,
115
+ `$t = New-ScheduledTaskTrigger -Daily -At '${SCHEDULE_TIME}'`,
116
+ '$s = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 15)',
117
+ `Register-ScheduledTask -TaskName ${psQuote(TASK)} -Action $a -Trigger $t -Settings $s -Description 'cc-cost daily sync' -Force | Out-Null`,
118
+ ].join('\n');
119
+ }
120
+ export function installSchedule(r) {
121
+ if (process.platform === 'win32') {
122
+ const res = powershell(windowsTaskScript(r));
123
+ return res.status === 0 ? { ok: true, what: `Task Scheduler "${TASK}", ${SCHEDULE_TIME}` } : { ok: false, what: (res.stderr || res.stdout || '').trim() };
124
+ }
125
+ if (process.platform === 'darwin') {
126
+ const plist = launchdPlist();
127
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
128
+ fs.writeFileSync(plist, plistFor(r));
129
+ const domain = `gui/${process.getuid()}`;
130
+ run('launchctl', ['bootout', `${domain}/${LAUNCHD_LABEL}`]); // not loaded yet is fine
131
+ const res = run('launchctl', ['bootstrap', domain, plist]);
132
+ if (res.status === 0)
133
+ return { ok: true, what: `${plist}, ${SCHEDULE_TIME}` };
134
+ fs.rmSync(plist, { force: true }); // a plist left behind would read as installed
135
+ return { ok: false, what: (res.stderr || '').trim() };
136
+ }
137
+ const units = systemdUnits(r), dir = systemdDir();
138
+ if (run('systemctl', ['--user', '--version']).status !== 0)
139
+ return { ok: false, what: 'systemctl --user is not available' };
140
+ fs.mkdirSync(dir, { recursive: true });
141
+ fs.writeFileSync(path.join(dir, 'cc-cost.service'), units.service);
142
+ fs.writeFileSync(path.join(dir, 'cc-cost.timer'), units.timer);
143
+ run('systemctl', ['--user', 'daemon-reload']);
144
+ const res = run('systemctl', ['--user', 'enable', '--now', 'cc-cost.timer']);
145
+ if (res.status === 0)
146
+ return { ok: true, what: `${path.join(dir, 'cc-cost.timer')}, ${SCHEDULE_TIME}` };
147
+ // e.g. no user session bus; unit files left behind would read as installed
148
+ for (const f of ['cc-cost.timer', 'cc-cost.service'])
149
+ fs.rmSync(path.join(dir, f), { force: true });
150
+ return { ok: false, what: (res.stderr || '').trim() };
151
+ }
152
+ export function scheduleInstalled() {
153
+ if (process.platform === 'win32')
154
+ return run('schtasks', ['/Query', '/TN', TASK]).status === 0;
155
+ if (process.platform === 'darwin')
156
+ return fs.existsSync(launchdPlist());
157
+ return fs.existsSync(path.join(systemdDir(), 'cc-cost.timer'));
158
+ }
159
+ export function removeSchedule() {
160
+ if (!scheduleInstalled())
161
+ return undefined;
162
+ if (process.platform === 'win32') {
163
+ run('schtasks', ['/Delete', '/TN', TASK, '/F']);
164
+ return `Task Scheduler "${TASK}"`;
165
+ }
166
+ if (process.platform === 'darwin') {
167
+ run('launchctl', ['bootout', `gui/${process.getuid()}/${LAUNCHD_LABEL}`]);
168
+ fs.rmSync(launchdPlist(), { force: true });
169
+ return launchdPlist();
170
+ }
171
+ run('systemctl', ['--user', 'disable', '--now', 'cc-cost.timer']);
172
+ for (const f of ['cc-cost.timer', 'cc-cost.service'])
173
+ fs.rmSync(path.join(systemdDir(), f), { force: true });
174
+ run('systemctl', ['--user', 'daemon-reload']);
175
+ return path.join(systemdDir(), 'cc-cost.timer');
176
+ }
177
+ export const claudeSettingsFile = () => path.join(claudeDirs()[0] ?? process.env.CLAUDE_CONFIG_DIR?.split(',')[0] ?? path.join(HOME, '.claude'), 'settings.json');
178
+ // Forward slashes work in every shell Claude Code may run hooks with, Git Bash included.
179
+ export const hookCommand = (r) => [r.node, r.script].map((p) => `"${p.replace(/\\/g, '/')}"`).join(' ') + ' sync --quiet';
180
+ const isOurs = (h) => /cc-cost/.test(h.command ?? '') && /\bsync --quiet\b/.test(h.command ?? '');
181
+ function readSettings(file) {
182
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
183
+ }
184
+ function writeSettings(file, s) {
185
+ fs.mkdirSync(path.dirname(file), { recursive: true });
186
+ // one-time copy of the user's file before we first change it
187
+ if (fs.existsSync(file) && !fs.existsSync(file + '.cc-cost.bak'))
188
+ fs.copyFileSync(file, file + '.cc-cost.bak');
189
+ fs.writeFileSync(file, JSON.stringify(s, null, 2) + '\n');
190
+ }
191
+ export function hookInstalled(file = claudeSettingsFile()) {
192
+ return (readSettings(file).hooks?.SessionEnd ?? []).some((e) => e.hooks?.some(isOurs));
193
+ }
194
+ // Adds the SessionEnd hook, or updates its command if cc-cost moved. Returns false if it was already current.
195
+ export function installHook(r, file = claudeSettingsFile()) {
196
+ const s = readSettings(file);
197
+ const entries = ((s.hooks ??= {}).SessionEnd ??= []);
198
+ const command = hookCommand(r);
199
+ const ours = entries.flatMap((e) => e.hooks ?? []).find(isOurs);
200
+ if (ours?.command === command)
201
+ return false;
202
+ if (ours)
203
+ ours.command = command;
204
+ // Synchronous with a short timeout: a sync takes about a second, and a background hook
205
+ // may be killed when Claude Code exits.
206
+ else
207
+ entries.push({ hooks: [{ type: 'command', command, timeout: 60 }] });
208
+ writeSettings(file, s);
209
+ return true;
210
+ }
211
+ export function removeHook(file = claudeSettingsFile()) {
212
+ if (!hookInstalled(file))
213
+ return false;
214
+ const s = readSettings(file);
215
+ const kept = (s.hooks.SessionEnd ?? [])
216
+ .map((e) => ({ ...e, hooks: e.hooks?.filter((h) => !isOurs(h)) }))
217
+ .filter((e) => e.hooks?.length);
218
+ if (kept.length)
219
+ s.hooks.SessionEnd = kept;
220
+ else
221
+ delete s.hooks.SessionEnd;
222
+ if (!Object.keys(s.hooks).length)
223
+ delete s.hooks;
224
+ writeSettings(file, s);
225
+ return true;
226
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { localClock } from "./scan.js";
4
+ import { T_LEN, UNKNOWN } from "./types.js";
5
+ // One file per machine: each machine only writes its own, so a sync service never sees two
6
+ // writers on the same file and can't produce conflicted copies.
7
+ export const machinesDir = (syncDir) => path.join(syncDir, 'machines');
8
+ const fileFor = (syncDir, machine) => path.join(machinesDir(syncDir), `${machine}.json`);
9
+ export const emptySnapshot = (machine, timezone) => ({
10
+ schema: 2, machine, updated: '', timezone, rows: [], hourly: [], sessions: {}, limits: [],
11
+ });
12
+ const isLegacy = (v) => typeof v === 'object' && v !== null && !('schema' in v) && typeof v.days === 'object';
13
+ export function fromLegacy(v1, machine) {
14
+ const snap = emptySnapshot(v1.host ?? machine, UNKNOWN);
15
+ snap.updated = v1.updated ?? '';
16
+ for (const [d, models] of Object.entries(v1.days))
17
+ for (const [key, t] of Object.entries(models)) {
18
+ const fast = key.endsWith('-fast');
19
+ const counters = [...t.slice(0, 6), ...new Array(T_LEN - 6).fill(0)];
20
+ snap.rows.push({ d, p: UNKNOWN, s: UNKNOWN, m: fast ? key.slice(0, -5) : key, ...(fast && { f: 1 }), t: counters });
21
+ }
22
+ return snap;
23
+ }
24
+ function readJson(file) {
25
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
26
+ }
27
+ // All machines in the sync folder. Legacy files count only for machines not yet on schema 2.
28
+ export function loadAll(syncDir) {
29
+ const byMachine = new Map();
30
+ if (fs.existsSync(machinesDir(syncDir)))
31
+ for (const f of fs.readdirSync(machinesDir(syncDir)).filter((f) => f.endsWith('.json'))) {
32
+ const snap = readJson(path.join(machinesDir(syncDir), f));
33
+ if (snap.schema === 2)
34
+ byMachine.set(snap.machine, snap);
35
+ }
36
+ if (fs.existsSync(syncDir))
37
+ for (const f of fs.readdirSync(syncDir).filter((f) => f.endsWith('.json'))) {
38
+ const v = readJson(path.join(syncDir, f));
39
+ if (!isLegacy(v))
40
+ continue;
41
+ const snap = fromLegacy(v, f.replace(/\.json$/, ''));
42
+ if (!byMachine.has(snap.machine))
43
+ byMachine.set(snap.machine, snap);
44
+ }
45
+ return [...byMachine.values()];
46
+ }
47
+ export function loadOwn(syncDir, machine, timezone) {
48
+ const file = fileFor(syncDir, machine);
49
+ if (fs.existsSync(file))
50
+ return readJson(file);
51
+ const legacy = path.join(syncDir, `${machine}.json`);
52
+ if (fs.existsSync(legacy)) {
53
+ const v = readJson(legacy);
54
+ if (isLegacy(v))
55
+ return fromLegacy(v, machine);
56
+ }
57
+ return emptySnapshot(machine, timezone);
58
+ }
59
+ // A scanned day replaces the stored one only when all of its transcripts must still be on disk,
60
+ // i.e. it is newer than `safeFrom`. Older scanned days can be partial (short sessions of that day
61
+ // already cleaned up, a long session reaching into it still there), so they only fill gaps.
62
+ export function merge(prev, scan, safeFrom, meta) {
63
+ const stored = new Set(prev.rows.map((r) => r.d));
64
+ const take = new Set([...scan.rows, ...scan.hourly].map((r) => r.d).filter((d) => d >= safeFrom || !stored.has(d)));
65
+ const sessions = { ...prev.sessions };
66
+ for (const [id, s] of Object.entries(scan.sessions)) {
67
+ const old = sessions[id];
68
+ sessions[id] = old ? { p: s.p, first: s.first < old.first ? s.first : old.first, last: s.last > old.last ? s.last : old.last } : s;
69
+ }
70
+ const limits = new Map();
71
+ // Like rows, a replaced day's limit events come from the scan only.
72
+ const day = localClock(meta.timezone);
73
+ for (const l of [...prev.limits.filter((x) => !take.has(day(x.ts).d)), ...scan.limits]) {
74
+ const key = `${l.type}|${l.status}|${l.resetsAt ?? l.ts}`;
75
+ const seen = limits.get(key);
76
+ if (!seen || l.ts < seen.ts)
77
+ limits.set(key, l);
78
+ }
79
+ return {
80
+ schema: 2,
81
+ machine: meta.machine,
82
+ updated: new Date().toISOString(),
83
+ timezone: meta.timezone,
84
+ rows: [...prev.rows.filter((r) => !take.has(r.d)), ...scan.rows.filter((r) => take.has(r.d))],
85
+ hourly: [...prev.hourly.filter((r) => !take.has(r.d)), ...scan.hourly.filter((r) => take.has(r.d))],
86
+ sessions,
87
+ limits: [...limits.values()].sort((a, b) => a.ts.localeCompare(b.ts)),
88
+ };
89
+ }
90
+ export function save(syncDir, snap) {
91
+ const file = fileFor(syncDir, snap.machine);
92
+ fs.mkdirSync(path.dirname(file), { recursive: true });
93
+ // write-then-rename so the sync client never uploads a half-written file
94
+ fs.writeFileSync(file + '.tmp', JSON.stringify(snap));
95
+ fs.renameSync(file + '.tmp', file);
96
+ return file;
97
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,17 @@
1
+ import path from 'node:path';
2
+ import { claudeDirs, retentionDays } from "./paths.js";
3
+ import { localClock, scan } from "./scan.js";
4
+ import { loadOwn, merge, save } from "./snapshot.js";
5
+ export const dashboardPath = (s) => path.join(s.syncDir, 'dashboard.html');
6
+ // Scan this machine's transcripts into its snapshot in the sync folder.
7
+ export async function sync(s) {
8
+ const dirs = claudeDirs();
9
+ const result = await scan(dirs, { timezone: s.timezone, anonymize: s.anonymize });
10
+ const prev = loadOwn(s.syncDir, s.machine, s.timezone);
11
+ const safeFrom = localClock(s.timezone)(new Date(Date.now() - (retentionDays(dirs) - 1) * 864e5).toISOString()).d;
12
+ const next = merge(prev, result, safeFrom, { machine: s.machine, timezone: s.timezone });
13
+ const file = save(s.syncDir, next);
14
+ const fromPrev = new Set(prev.rows);
15
+ const kept = new Set(next.rows.filter((r) => fromPrev.has(r)).map((r) => r.d)).size;
16
+ return { scan: result, file, kept };
17
+ }
package/dist/types.js ADDED
@@ -0,0 +1,23 @@
1
+ // Snapshot format, schema 2. Everything here is synced between machines and outlives the
2
+ // transcripts it came from, so fields are append-only: never repurpose an index or a key.
3
+ // Positions in a token counter array (Row.t, HourRow.t).
4
+ export const T = {
5
+ input: 0,
6
+ write5m: 1,
7
+ write1h: 2,
8
+ read: 3,
9
+ output: 4,
10
+ calls: 5,
11
+ thinking: 6,
12
+ webSearch: 7,
13
+ webFetch: 8,
14
+ maxContext: 9, // largest single-call context (input + cache write + cache read)
15
+ // calls by context size: < 50K, 50K-200K, 200K-500K, >= 500K tokens
16
+ ctx50k: 10,
17
+ ctx200k: 11,
18
+ ctx500k: 12,
19
+ ctxOver500k: 13,
20
+ };
21
+ export const T_LEN = 14;
22
+ export const CTX_BOUNDS = [50_000, 200_000, 500_000];
23
+ export const UNKNOWN = '(unknown)';
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@ugurcandede/cc-cost",
3
+ "version": "0.1.0",
4
+ "description": "API-equivalent cost of your Claude Code usage across all your machines, with an archive that outlives transcript cleanup and an offline dashboard.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "anthropic",
9
+ "cost",
10
+ "usage",
11
+ "tokens",
12
+ "pricing",
13
+ "prompt-caching",
14
+ "subscription",
15
+ "cli",
16
+ "dashboard",
17
+ "analytics"
18
+ ],
19
+ "homepage": "https://github.com/ugurcandede/cc-cost#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/ugurcandede/cc-cost/issues"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/ugurcandede/cc-cost.git"
26
+ },
27
+ "license": "MIT",
28
+ "author": {
29
+ "name": "Ugurcan Dede",
30
+ "email": "ugurcan.dede@outlook.com.tr",
31
+ "url": "https://ugurcandede.github.io"
32
+ },
33
+ "contributors": [
34
+ {
35
+ "name": "Claude",
36
+ "email": "noreply@anthropic.com",
37
+ "url": "https://claude.com/claude-code"
38
+ }
39
+ ],
40
+ "type": "module",
41
+ "bin": {
42
+ "cc-cost": "dist/cli.js"
43
+ },
44
+ "files": [
45
+ "dist"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
52
+ "dev": "node src/cli.ts",
53
+ "test": "node --test \"test/**/*.test.ts\"",
54
+ "typecheck": "tsc --noEmit",
55
+ "prepack": "yarn typecheck && yarn test && yarn build"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^22.20.3",
59
+ "typescript": "^6.0.3"
60
+ },
61
+ "engines": {
62
+ "node": ">=22"
63
+ },
64
+ "devEngines": {
65
+ "runtime": {
66
+ "name": "node",
67
+ "version": ">=22.18"
68
+ }
69
+ },
70
+ "os": [
71
+ "win32",
72
+ "darwin",
73
+ "linux"
74
+ ],
75
+ "packageManager": "yarn@4.13.0"
76
+ }