@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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/aggregate.js +107 -0
- package/dist/cli.js +351 -0
- package/dist/config.js +15 -0
- package/dist/dashboard.js +692 -0
- package/dist/format.js +33 -0
- package/dist/i18n.js +415 -0
- package/dist/paths.js +38 -0
- package/dist/pricing.js +136 -0
- package/dist/reports.js +410 -0
- package/dist/scan.js +178 -0
- package/dist/setup.js +226 -0
- package/dist/snapshot.js +97 -0
- package/dist/sync.js +17 -0
- package/dist/types.js +23 -0
- package/package.json +76 -0
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
|
+
}
|
package/dist/snapshot.js
ADDED
|
@@ -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
|
+
}
|