@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ugurcan Dede
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="docs/assets/logo.svg" alt="cc-cost" width="88">
|
|
3
|
+
<h1>cc-cost</h1>
|
|
4
|
+
<p>What your Claude Code usage would cost at API list prices, totalled across all your machines (Windows, Mac, Linux), with an archive that outlives Claude Code's transcript cleanup and a dashboard you open with a double-click.</p>
|
|
5
|
+
<br>
|
|
6
|
+
<a href="https://github.com/ugurcandede/cc-cost" target="_blank"><img src="https://img.shields.io/badge/version-0.1.0-blue?style=flat-square" alt="Version 0.1.0"></a>
|
|
7
|
+
<img src="https://img.shields.io/badge/Node.js-22%2B%20·%20zero%20dependencies-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+, zero dependencies">
|
|
8
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT"></a>
|
|
9
|
+
<br>
|
|
10
|
+
<img src="https://img.shields.io/badge/Windows-0078D6?style=flat-square&logo=data:image/svg%2Bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0zIDUuNUwxMCA0LjVWMTEuNUgzVjUuNU0xMSA0LjVMMjEgM1YxMS41SDExVjQuNU0zIDEyLjVIMTBWMTkuNUwzIDE4LjVWMTIuNU0xMSAxMi41SDIxVjIxTDExIDE5LjVWMTIuNVoiLz48L3N2Zz4=" alt="Windows">
|
|
11
|
+
<img src="https://img.shields.io/badge/macOS-000?style=flat-square&logo=apple&logoColor=white" alt="macOS">
|
|
12
|
+
<img src="https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black" alt="Linux">
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<br>
|
|
16
|
+
<p align="center">
|
|
17
|
+
<picture>
|
|
18
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/dashboard-dark.png">
|
|
19
|
+
<img src="docs/assets/dashboard-light.png" alt="The cc-cost dashboard: the last 30 days across two machines, daily cost stacked by model" width="800">
|
|
20
|
+
</picture>
|
|
21
|
+
<br>
|
|
22
|
+
<sub>The dashboard, on sample data.</sub>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm i -g @ugurcandede/cc-cost # or, with Yarn 1: yarn global add @ugurcandede/cc-cost
|
|
29
|
+
|
|
30
|
+
# Pick the folder your machines share (Dropbox, iCloud Drive, OneDrive, …),
|
|
31
|
+
# your language and plan; schedule a daily sync and add the Claude Code hook.
|
|
32
|
+
cc-cost setup
|
|
33
|
+
|
|
34
|
+
cc-cost # sync this machine, print a summary
|
|
35
|
+
cc-cost report --open # the dashboard
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Run `setup` on every machine and point them at the same folder. Each one adds its numbers; every one
|
|
39
|
+
sees the total.
|
|
40
|
+
|
|
41
|
+
Yarn 2 and later have no global installs: install with npm, or try a report once with
|
|
42
|
+
`yarn dlx @ugurcandede/cc-cost` (or `npx @ugurcandede/cc-cost`). `setup` needs a permanent install,
|
|
43
|
+
since the schedule and hook point at it.
|
|
44
|
+
|
|
45
|
+
## Prerequisites
|
|
46
|
+
|
|
47
|
+
- Node.js 22 or newer (no runtime dependencies)
|
|
48
|
+
- Claude Code, which keeps its transcripts in `~/.claude/projects`
|
|
49
|
+
- For several machines: a folder they all sync. Without one, cc-cost counts this machine only
|
|
50
|
+
|
|
51
|
+
## Everyday commands
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cc-cost # sync, summary, dashboard
|
|
55
|
+
cc-cost daily --last 7 # also weekly, monthly; --breakdown adds per-model rows
|
|
56
|
+
cc-cost projects # also models, machines, sessions
|
|
57
|
+
cc-cost agents # main thread vs subagents; also skills, mcp
|
|
58
|
+
cc-cost insights # context size, cache efficiency, what drives the cost
|
|
59
|
+
cc-cost plan # API equivalent vs Pro / Max 5x / Max 20x, rate-limit hits
|
|
60
|
+
cc-cost blocks # usage in Claude's 5-hour windows
|
|
61
|
+
cc-cost status # settings, machines, schedule, hook
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
$ cc-cost projects --last 30
|
|
66
|
+
Project Calls Avg context Cache read Output Cost Share
|
|
67
|
+
───────────── ────── ─────────── ────────── ────── ───────── ──────
|
|
68
|
+
api-server 4,728 207.8K 966.9M 5.7M $722.12 45.6%
|
|
69
|
+
web-app 3,051 237.9K 714.2M 3.4M $446.28 28.2%
|
|
70
|
+
mobile-app 1,443 214.9K 305.1M 1.7M $198.60 12.6%
|
|
71
|
+
data-pipeline 533 224.9K 118.0M 500.6K $90.86 5.7%
|
|
72
|
+
infra 557 231.2K 126.7M 677.1K $77.03 4.9%
|
|
73
|
+
docs-site 312 265.8K 81.6M 345.3K $47.20 3.0%
|
|
74
|
+
───────────── ────── ─────────── ────────── ────── ───────── ──────
|
|
75
|
+
Total 10,624 221.2K 2.31B 12.3M $1,582.10 100.0%
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every report takes the same filters and output options:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cc-cost daily --since 2026-09-01 --machine macbook --model opus
|
|
82
|
+
cc-cost sessions --project api --limit 10
|
|
83
|
+
cc-cost monthly --json # or --csv
|
|
84
|
+
cc-cost insights --lang tr # interface language: en (default) or tr
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## How it differs from other tools
|
|
88
|
+
|
|
89
|
+
- **Several machines, one total**: through a folder you already sync; no server, no account.
|
|
90
|
+
- **History that stays**: numbers are kept after Claude Code deletes old transcripts.
|
|
91
|
+
- **A dashboard in one file**: date, machine, project and model filters, works offline.
|
|
92
|
+
- **Insights, not just totals**: context size per call, cache hit ratio, subagents, skills, MCP servers,
|
|
93
|
+
the costliest sessions.
|
|
94
|
+
- **Current prices**: read from Anthropic's pricing page daily, with a bundled fallback.
|
|
95
|
+
|
|
96
|
+
## Layout
|
|
97
|
+
|
|
98
|
+
What cc-cost keeps in the shared folder:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
<shared folder>/
|
|
102
|
+
├── machines/
|
|
103
|
+
│ ├── work-pc.json # one per machine, written only by that machine
|
|
104
|
+
│ └── macbook.json
|
|
105
|
+
└── dashboard.html # rewritten on every sync
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
and on each machine:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
~/.config/cc-cost/ # %APPDATA%\cc-cost on Windows
|
|
112
|
+
├── config.json # cc-cost config
|
|
113
|
+
└── pricing-cache.json # last prices read from the pricing page
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Safety
|
|
117
|
+
|
|
118
|
+
- **No conversation content leaves your machine.** Snapshots hold token and call counts per day, project,
|
|
119
|
+
session and model, plus rate-limit events. Project names can be hashed (`anonymizeProjects`).
|
|
120
|
+
- **The archive only grows.** A day older than Claude Code's cleanup window is never overwritten by a
|
|
121
|
+
scan that may see only part of it.
|
|
122
|
+
- **No sync conflicts.** Each machine writes only its own file, through write-then-rename.
|
|
123
|
+
- **Your Claude Code settings are backed up** to `settings.json.cc-cost.bak` before the hook is added;
|
|
124
|
+
other hooks are left alone. `cc-cost setup --remove` takes the schedule and the hook away.
|
|
125
|
+
- Read-only toward transcripts: cc-cost never modifies `~/.claude/projects`.
|
|
126
|
+
|
|
127
|
+
## Further reading
|
|
128
|
+
|
|
129
|
+
- [docs/cli.md](docs/cli.md): every command, filter and option
|
|
130
|
+
- [docs/configuration.md](docs/configuration.md): settings, the shared folder, price overrides, privacy
|
|
131
|
+
- [docs/how-it-works.md](docs/how-it-works.md): how costs are calculated, deduplication, the archive,
|
|
132
|
+
and what cc-cost can't see
|
|
133
|
+
- [docs/maintenance.md](docs/maintenance.md): automatic runs, status, moving from the original script
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
<p align="center">
|
|
137
|
+
Built with ❤️ for the Claude Community
|
|
138
|
+
</p>
|
|
139
|
+
|
|
140
|
+
<p align="center">
|
|
141
|
+
<a href="https://github.com/ugurcandede">
|
|
142
|
+
<img src="https://img.shields.io/badge/@ugurcandede-181717?logo=github&logoColor=white" alt="GitHub · @ugurcandede">
|
|
143
|
+
</a>
|
|
144
|
+
<a href="https://claude.com/claude-code">
|
|
145
|
+
<img src="https://img.shields.io/badge/developed%20with-Claude%20Code-D97757?logo=claude&logoColor=white" alt="Developed with Claude Code">
|
|
146
|
+
</a>
|
|
147
|
+
</p>
|
|
148
|
+
|
|
149
|
+
<p align="center">
|
|
150
|
+
<sub>Not affiliated with or endorsed by Anthropic. "Claude" is a trademark of Anthropic, PBC.</sub>
|
|
151
|
+
</p>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { priceFor } from "./pricing.js";
|
|
2
|
+
import { T, T_LEN } from "./types.js";
|
|
3
|
+
export const modelLabel = (r) => (r.f ? `${r.m}-fast` : r.m);
|
|
4
|
+
export const agentOf = (r) => r.a ?? 'main';
|
|
5
|
+
const n = (t, i) => t[i] ?? 0;
|
|
6
|
+
export function costOf(t, p, webSearch) {
|
|
7
|
+
return [
|
|
8
|
+
(n(t, T.input) * p[0]) / 1e6,
|
|
9
|
+
(n(t, T.write5m) * p[1] + n(t, T.write1h) * p[2]) / 1e6,
|
|
10
|
+
(n(t, T.read) * p[3]) / 1e6,
|
|
11
|
+
(n(t, T.output) * p[4]) / 1e6,
|
|
12
|
+
n(t, T.webSearch) * webSearch,
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
const sum = (c) => c[0] + c[1] + c[2] + c[3] + c[4];
|
|
16
|
+
export function priceRows(snaps, table, overrides = {}) {
|
|
17
|
+
const rows = [];
|
|
18
|
+
const hours = [];
|
|
19
|
+
const limits = [];
|
|
20
|
+
const unknown = new Set();
|
|
21
|
+
for (const snap of snaps) {
|
|
22
|
+
for (const r of snap.rows) {
|
|
23
|
+
const p = priceFor(table, r.m, !!r.f, overrides);
|
|
24
|
+
if (!p) {
|
|
25
|
+
unknown.add(modelLabel(r));
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const c = costOf(r.t, p, table.webSearch);
|
|
29
|
+
rows.push({ ...r, machine: snap.machine, c, cost: sum(c) });
|
|
30
|
+
}
|
|
31
|
+
for (const h of snap.hourly) {
|
|
32
|
+
const p = priceFor(table, h.m, !!h.f, overrides);
|
|
33
|
+
if (p)
|
|
34
|
+
hours.push({ ...h, machine: snap.machine, cost: sum(costOf(h.t, p, table.webSearch)) });
|
|
35
|
+
}
|
|
36
|
+
for (const l of snap.limits)
|
|
37
|
+
limits.push({ ...l, machine: snap.machine });
|
|
38
|
+
}
|
|
39
|
+
return { rows, hours, limits, unknown };
|
|
40
|
+
}
|
|
41
|
+
// What the same tokens would cost on another model at standard speed
|
|
42
|
+
export function repriceAs(rows, table, model) {
|
|
43
|
+
const p = table.models[model];
|
|
44
|
+
return p && rows.reduce((acc, r) => acc + sum(costOf(r.t, p, table.webSearch)), 0);
|
|
45
|
+
}
|
|
46
|
+
// Name filters match case-insensitive substrings ("opus" -> every Opus model); sessions match by id prefix.
|
|
47
|
+
const matches = (wanted, value) => !wanted?.length || wanted.some((w) => value.toLowerCase().includes(w.toLowerCase()));
|
|
48
|
+
export function applyFilter(rows, f) {
|
|
49
|
+
return rows.filter((r) => (!f.since || r.d >= f.since) &&
|
|
50
|
+
(!f.until || r.d <= f.until) &&
|
|
51
|
+
matches(f.machine, r.machine) &&
|
|
52
|
+
matches(f.project, r.p) &&
|
|
53
|
+
matches(f.model, modelLabel(r)) &&
|
|
54
|
+
(!f.session?.length || f.session.some((s) => r.s.startsWith(s))) &&
|
|
55
|
+
matches(f.agent, agentOf(r)) &&
|
|
56
|
+
matches(f.skill, r.k ?? '') &&
|
|
57
|
+
matches(f.mcp, r.x ?? ''));
|
|
58
|
+
}
|
|
59
|
+
// Hourly data has no project, session or attribution; only these filters apply to it.
|
|
60
|
+
export const hourFilterable = (f) => !f.project?.length && !f.session?.length && !f.agent?.length && !f.skill?.length && !f.mcp?.length;
|
|
61
|
+
export function applyHourFilter(hours, f) {
|
|
62
|
+
return hours.filter((h) => (!f.since || h.d >= f.since) && (!f.until || h.d <= f.until) && matches(f.machine, h.machine) && matches(f.model, modelLabel(h)));
|
|
63
|
+
}
|
|
64
|
+
const emptyTotals = () => ({
|
|
65
|
+
cost: 0, c: [0, 0, 0, 0, 0], t: new Array(T_LEN).fill(0), sessions: new Set(), days: new Set(), machines: new Set(),
|
|
66
|
+
});
|
|
67
|
+
function add(acc, r) {
|
|
68
|
+
acc.cost += r.cost;
|
|
69
|
+
for (let i = 0; i < 5; i++)
|
|
70
|
+
acc.c[i] += r.c[i];
|
|
71
|
+
for (let i = 0; i < T_LEN; i++)
|
|
72
|
+
acc.t[i] = i === T.maxContext ? Math.max(acc.t[i], n(r.t, i)) : acc.t[i] + n(r.t, i);
|
|
73
|
+
acc.sessions.add(r.s);
|
|
74
|
+
acc.days.add(r.d);
|
|
75
|
+
acc.machines.add(r.machine);
|
|
76
|
+
}
|
|
77
|
+
export function totals(rows) {
|
|
78
|
+
const acc = emptyTotals();
|
|
79
|
+
for (const r of rows)
|
|
80
|
+
add(acc, r);
|
|
81
|
+
return acc;
|
|
82
|
+
}
|
|
83
|
+
export function groupBy(rows, key) {
|
|
84
|
+
const out = new Map();
|
|
85
|
+
for (const r of rows) {
|
|
86
|
+
const k = key(r);
|
|
87
|
+
let acc = out.get(k);
|
|
88
|
+
if (!acc)
|
|
89
|
+
out.set(k, (acc = emptyTotals()));
|
|
90
|
+
add(acc, r);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
export const byCost = (m) => [...m].sort((a, b) => b[1].cost - a[1].cost);
|
|
95
|
+
// Monday of the ISO week containing the date
|
|
96
|
+
export function weekOf(date) {
|
|
97
|
+
const t = new Date(date + 'T00:00:00Z');
|
|
98
|
+
t.setUTCDate(t.getUTCDate() - ((t.getUTCDay() + 6) % 7));
|
|
99
|
+
return t.toISOString().slice(0, 10);
|
|
100
|
+
}
|
|
101
|
+
export const contextTokens = (t) => n(t, T.input) + n(t, T.write5m) + n(t, T.write1h) + n(t, T.read);
|
|
102
|
+
// Mean context per call: every call re-reads its whole prompt, which is what drives cache-read cost.
|
|
103
|
+
export const avgContext = (t) => (n(t, T.calls) ? contextTokens(t) / n(t, T.calls) : 0);
|
|
104
|
+
// Share of prompt tokens served from cache
|
|
105
|
+
export const cacheHitRatio = (t) => (contextTokens(t) ? n(t, T.read) / contextTokens(t) : 0);
|
|
106
|
+
// Calendar days from `from` to `to`, inclusive
|
|
107
|
+
export const daysBetween = (from, to) => Math.round((Date.parse(to) - Date.parse(from)) / 864e5) + 1;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { applyFilter, applyHourFilter, hourFilterable, priceRows } from "./aggregate.js";
|
|
9
|
+
import { CONFIG_KEYS, configFile, loadConfig, PLANS, saveConfig } from "./config.js";
|
|
10
|
+
import { writeDashboard } from "./dashboard.js";
|
|
11
|
+
import { paint, setColor, table } from "./format.js";
|
|
12
|
+
import { fill, L, lang, LANGS, setLang } from "./i18n.js";
|
|
13
|
+
import { claudeDirs, configDir, defaultMachine, retentionDays } from "./paths.js";
|
|
14
|
+
import { loadPrices } from "./pricing.js";
|
|
15
|
+
import { blocksReport, dimensionJson, dimensionReport, DIMENSIONS, insights, limitsInRange, periodJson, periodReport, PERIODS, planReport, renderCsv, renderTable, summary, summaryJson, } from "./reports.js";
|
|
16
|
+
import { localClock } from "./scan.js";
|
|
17
|
+
import { claudeSettingsFile, currentRunner, dataFolderIn, hookInstalled, installHook, installSchedule, isEphemeral, removeHook, removeSchedule, SCHEDULE_TIME, scheduleInstalled, syncFolders, } from "./setup.js";
|
|
18
|
+
import { loadAll } from "./snapshot.js";
|
|
19
|
+
import { dashboardPath, sync } from "./sync.js";
|
|
20
|
+
// Name, version and project page come from package.json only
|
|
21
|
+
const pkg = createRequire(import.meta.url)('../package.json');
|
|
22
|
+
const REPO = pkg.homepage.replace(/#.*$/, '');
|
|
23
|
+
const NPM = `https://www.npmjs.com/package/${pkg.name}`;
|
|
24
|
+
const { values: opt, positionals } = parseArgs({
|
|
25
|
+
allowPositionals: true,
|
|
26
|
+
options: {
|
|
27
|
+
since: { type: 'string' },
|
|
28
|
+
until: { type: 'string' },
|
|
29
|
+
last: { type: 'string' },
|
|
30
|
+
machine: { type: 'string', multiple: true },
|
|
31
|
+
project: { type: 'string', multiple: true },
|
|
32
|
+
model: { type: 'string', multiple: true },
|
|
33
|
+
session: { type: 'string', multiple: true },
|
|
34
|
+
agent: { type: 'string', multiple: true },
|
|
35
|
+
skill: { type: 'string', multiple: true },
|
|
36
|
+
mcp: { type: 'string', multiple: true },
|
|
37
|
+
json: { type: 'boolean' },
|
|
38
|
+
csv: { type: 'boolean' },
|
|
39
|
+
breakdown: { type: 'boolean' },
|
|
40
|
+
limit: { type: 'string' },
|
|
41
|
+
lang: { type: 'string' },
|
|
42
|
+
tz: { type: 'string' },
|
|
43
|
+
'no-color': { type: 'boolean' },
|
|
44
|
+
'no-sync': { type: 'boolean' },
|
|
45
|
+
offline: { type: 'boolean' },
|
|
46
|
+
quiet: { type: 'boolean' },
|
|
47
|
+
open: { type: 'boolean' },
|
|
48
|
+
refresh: { type: 'boolean' },
|
|
49
|
+
'sync-dir': { type: 'string' },
|
|
50
|
+
yes: { type: 'boolean', short: 'y' },
|
|
51
|
+
'no-schedule': { type: 'boolean' },
|
|
52
|
+
'no-hook': { type: 'boolean' },
|
|
53
|
+
remove: { type: 'boolean' },
|
|
54
|
+
help: { type: 'boolean', short: 'h' },
|
|
55
|
+
version: { type: 'boolean', short: 'v' },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
let config = loadConfig();
|
|
59
|
+
const resolveSettings = () => ({
|
|
60
|
+
syncDir: path.resolve(opt['sync-dir'] ?? process.env.CC_COST_DIR ?? config.syncDir ?? path.join(configDir(), 'data')),
|
|
61
|
+
machine: config.machine ?? defaultMachine(),
|
|
62
|
+
lang: opt.lang ?? config.lang ?? 'en',
|
|
63
|
+
timezone: opt.tz ?? config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
64
|
+
planPrice: PLANS[config.plan ?? 'max5x'] ?? 100,
|
|
65
|
+
anonymize: !!config.anonymizeProjects,
|
|
66
|
+
pricing: config.pricing ?? {},
|
|
67
|
+
});
|
|
68
|
+
let settings = resolveSettings();
|
|
69
|
+
setLang(settings.lang);
|
|
70
|
+
setColor(!opt['no-color']);
|
|
71
|
+
const out = (text) => {
|
|
72
|
+
if (!opt.quiet)
|
|
73
|
+
console.log(text);
|
|
74
|
+
};
|
|
75
|
+
function fail(message) {
|
|
76
|
+
console.error(message);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const list = (v) => v?.flatMap((s) => s.split(',')).map((s) => s.trim()).filter(Boolean);
|
|
80
|
+
function date(flag, v) {
|
|
81
|
+
if (v !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(v))
|
|
82
|
+
fail(fill(L().badDate, { flag, value: v }));
|
|
83
|
+
return v;
|
|
84
|
+
}
|
|
85
|
+
function count(flag, v) {
|
|
86
|
+
if (v === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const n = Number(v);
|
|
89
|
+
if (!Number.isInteger(n) || n < 1)
|
|
90
|
+
fail(fill(L().badNumber, { flag, value: v }));
|
|
91
|
+
return n;
|
|
92
|
+
}
|
|
93
|
+
const localDay = (daysAgo) => localClock(settings.timezone)(new Date(Date.now() - daysAgo * 864e5).toISOString()).d;
|
|
94
|
+
function filter() {
|
|
95
|
+
const last = count('--last', opt.last);
|
|
96
|
+
return {
|
|
97
|
+
since: last ? localDay(last - 1) : date('--since', opt.since),
|
|
98
|
+
until: date('--until', opt.until),
|
|
99
|
+
machine: list(opt.machine),
|
|
100
|
+
project: list(opt.project),
|
|
101
|
+
model: list(opt.model),
|
|
102
|
+
session: list(opt.session),
|
|
103
|
+
agent: list(opt.agent),
|
|
104
|
+
skill: list(opt.skill),
|
|
105
|
+
mcp: list(opt.mcp),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function openFile(file) {
|
|
109
|
+
const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', file]]
|
|
110
|
+
: process.platform === 'darwin' ? ['open', [file]]
|
|
111
|
+
: ['xdg-open', [file]];
|
|
112
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
113
|
+
}
|
|
114
|
+
const pricesCache = () => path.join(configDir(), 'pricing-cache.json');
|
|
115
|
+
async function data(verbose) {
|
|
116
|
+
if (!opt['no-sync']) {
|
|
117
|
+
const r = await sync(settings);
|
|
118
|
+
if (verbose) {
|
|
119
|
+
out(fill(L().scanned, { machine: settings.machine, files: r.scan.files, dupes: r.scan.duplicates }));
|
|
120
|
+
out(fill(L().snapshot, { file: r.file }) + (r.kept ? fill(L().kept, { n: r.kept }) : ''));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const prices = await loadPrices(pricesCache(), { offline: opt.offline });
|
|
124
|
+
const snaps = loadAll(settings.syncDir);
|
|
125
|
+
return { ...priceRows(snaps, prices.table, settings.pricing), prices, snaps };
|
|
126
|
+
}
|
|
127
|
+
function dashboard(d) {
|
|
128
|
+
const dates = d.rows.map((r) => r.d).sort();
|
|
129
|
+
const t = d.prices.table;
|
|
130
|
+
return writeDashboard(d, {
|
|
131
|
+
lang: settings.lang,
|
|
132
|
+
timezone: settings.timezone,
|
|
133
|
+
planPrice: settings.planPrice,
|
|
134
|
+
updated: new Date().toISOString(),
|
|
135
|
+
machines: d.snaps.length,
|
|
136
|
+
first: dates[0] ?? '',
|
|
137
|
+
last: dates[dates.length - 1] ?? '',
|
|
138
|
+
prices: `${t.source === 'bundled' ? 'bundled' : 'platform.claude.com'} ${t.date.slice(0, 10)}`,
|
|
139
|
+
version: pkg.version,
|
|
140
|
+
repo: REPO,
|
|
141
|
+
npm: NPM,
|
|
142
|
+
site: pkg.author.url,
|
|
143
|
+
}, dashboardPath(settings));
|
|
144
|
+
}
|
|
145
|
+
function footer(d) {
|
|
146
|
+
if (d.prices.note)
|
|
147
|
+
out(fill(L().pricesFallback, { reason: d.prices.note, source: d.prices.table.source, date: d.prices.table.date.slice(0, 10) }));
|
|
148
|
+
if (d.unknown.size)
|
|
149
|
+
out(fill(L().unknownModels, { list: [...d.unknown].join(', ') }));
|
|
150
|
+
}
|
|
151
|
+
// Print a table report as a table, CSV or JSON
|
|
152
|
+
function emit(report, json, empty) {
|
|
153
|
+
if (opt.json)
|
|
154
|
+
return out(JSON.stringify(json, null, 2));
|
|
155
|
+
if (opt.csv)
|
|
156
|
+
return out(renderCsv(report));
|
|
157
|
+
out(empty ? L().noData : renderTable(report));
|
|
158
|
+
}
|
|
159
|
+
const machineMatch = (f) => (m) => !f.machine?.length || f.machine.some((w) => m.toLowerCase().includes(w.toLowerCase()));
|
|
160
|
+
async function ask(rl, question) {
|
|
161
|
+
return rl ? (await rl.question(question)).trim() : '';
|
|
162
|
+
}
|
|
163
|
+
const yes = (answer) => answer === '' || /^[yeYE]/.test(answer);
|
|
164
|
+
async function setup() {
|
|
165
|
+
const S = L().setup;
|
|
166
|
+
const runner = currentRunner();
|
|
167
|
+
if (opt.remove) {
|
|
168
|
+
const removed = [removeSchedule(), removeHook() ? claudeSettingsFile() : undefined].filter(Boolean);
|
|
169
|
+
return out(removed.length ? removed.map((w) => fill(S.removed, { what: w })).join('\n') : S.nothingToRemove);
|
|
170
|
+
}
|
|
171
|
+
const interactive = !opt.yes && process.stdin.isTTY;
|
|
172
|
+
const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
173
|
+
try {
|
|
174
|
+
out(paint('bold', fill(S.title, { version: pkg.version })));
|
|
175
|
+
const next = { ...config };
|
|
176
|
+
if (opt['sync-dir'])
|
|
177
|
+
next.syncDir = path.resolve(opt['sync-dir']);
|
|
178
|
+
else {
|
|
179
|
+
const folders = syncFolders();
|
|
180
|
+
const proposal = next.syncDir ?? (folders[0] && dataFolderIn(folders[0]));
|
|
181
|
+
if (folders.length)
|
|
182
|
+
out(fill(S.found, { list: folders.join(', ') }));
|
|
183
|
+
let chosen = proposal && yes(await ask(rl, fill(S.usePath, { path: proposal }))) ? proposal : undefined;
|
|
184
|
+
if (!chosen && rl)
|
|
185
|
+
chosen = (await ask(rl, S.askPath)) || undefined;
|
|
186
|
+
if (chosen)
|
|
187
|
+
next.syncDir = path.resolve(chosen);
|
|
188
|
+
else
|
|
189
|
+
out(S.localOnly);
|
|
190
|
+
}
|
|
191
|
+
const langAnswer = opt.lang ?? (await ask(rl, fill(S.lang, { current: next.lang ?? 'en' })));
|
|
192
|
+
if (langAnswer && LANGS[langAnswer])
|
|
193
|
+
next.lang = langAnswer;
|
|
194
|
+
const planAnswer = await ask(rl, fill(S.plan, { current: next.plan ?? 'max5x' }));
|
|
195
|
+
if (planAnswer && PLANS[planAnswer])
|
|
196
|
+
next.plan = planAnswer;
|
|
197
|
+
next.plan ??= 'max5x';
|
|
198
|
+
saveConfig(next);
|
|
199
|
+
config = next;
|
|
200
|
+
settings = resolveSettings();
|
|
201
|
+
setLang(settings.lang);
|
|
202
|
+
out(fill(L().setup.saved, { file: configFile() }));
|
|
203
|
+
const wantSchedule = !opt['no-schedule'] && yes(await ask(rl, fill(S.schedule, { time: SCHEDULE_TIME })));
|
|
204
|
+
const wantHook = !opt['no-hook'] && yes(await ask(rl, fill(S.hook, { file: claudeSettingsFile() })));
|
|
205
|
+
if ((wantSchedule || wantHook) && isEphemeral(runner))
|
|
206
|
+
out(S.npx);
|
|
207
|
+
else {
|
|
208
|
+
if (wantSchedule) {
|
|
209
|
+
const r = installSchedule(runner);
|
|
210
|
+
out(fill(r.ok ? S.scheduled : S.scheduleFailed, r.ok ? { what: r.what } : { reason: r.what }));
|
|
211
|
+
}
|
|
212
|
+
if (wantHook)
|
|
213
|
+
out(fill(installHook(runner) ? S.hookAdded : S.hookKept, { file: claudeSettingsFile() }));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
rl?.close();
|
|
218
|
+
}
|
|
219
|
+
out('\n' + S.firstSync);
|
|
220
|
+
const d = await data(true);
|
|
221
|
+
out(fill(L().dashboard, { file: dashboard(d) }));
|
|
222
|
+
}
|
|
223
|
+
async function status() {
|
|
224
|
+
const St = L().status;
|
|
225
|
+
const snaps = loadAll(settings.syncDir);
|
|
226
|
+
const prices = await loadPrices(pricesCache(), { offline: true });
|
|
227
|
+
const info = {
|
|
228
|
+
version: pkg.version,
|
|
229
|
+
config: configFile(),
|
|
230
|
+
syncDir: settings.syncDir,
|
|
231
|
+
machine: settings.machine,
|
|
232
|
+
machines: snaps.map((s) => ({ name: s.machine, updated: s.updated, days: new Set(s.rows.map((r) => r.d)).size })),
|
|
233
|
+
retentionDays: retentionDays(claudeDirs()),
|
|
234
|
+
schedule: scheduleInstalled(),
|
|
235
|
+
hook: hookInstalled(),
|
|
236
|
+
prices: { source: prices.table.source, date: prices.table.date },
|
|
237
|
+
};
|
|
238
|
+
if (opt.json)
|
|
239
|
+
return out(JSON.stringify(info, null, 2));
|
|
240
|
+
const when = (iso) => (iso ? new Date(iso).toLocaleString(lang() === 'tr' ? 'tr-TR' : 'en-US', { timeZone: settings.timezone }) : '–');
|
|
241
|
+
const pairs = [
|
|
242
|
+
[St.version, info.version],
|
|
243
|
+
[St.config, info.config + (fs.existsSync(info.config) ? '' : ' (–)')],
|
|
244
|
+
[St.syncDir, info.syncDir],
|
|
245
|
+
[St.machine, info.machine],
|
|
246
|
+
[St.machines, info.machines.length ? info.machines.map((m) => fill(St.machineLine, { name: m.name, updated: when(m.updated), days: m.days })).join('\n' + ' '.repeat(0)) : St.none],
|
|
247
|
+
[St.retention, fill(St.retentionValue, { n: info.retentionDays })],
|
|
248
|
+
[St.scheduler, info.schedule ? St.yes : St.no],
|
|
249
|
+
[St.hook, info.hook ? St.yes : St.no],
|
|
250
|
+
[St.prices, `${info.prices.source}, ${info.prices.date.slice(0, 10)}`],
|
|
251
|
+
];
|
|
252
|
+
const w = Math.max(...pairs.map(([k]) => k.length));
|
|
253
|
+
for (const [k, v] of pairs)
|
|
254
|
+
out(paint('dim', k.padEnd(w)) + ' ' + v.split('\n').join('\n' + ' '.repeat(w + 2)));
|
|
255
|
+
}
|
|
256
|
+
async function main() {
|
|
257
|
+
if (opt.help)
|
|
258
|
+
return out(fill(L().help, { version: pkg.version, url: REPO }));
|
|
259
|
+
if (opt.version)
|
|
260
|
+
return out(pkg.version);
|
|
261
|
+
const [cmd = '', ...rest] = positionals;
|
|
262
|
+
const tableCommands = [...PERIODS, ...DIMENSIONS, 'plan', 'blocks'];
|
|
263
|
+
if (opt.csv && !tableCommands.includes(cmd))
|
|
264
|
+
fail(L().csvUnsupported);
|
|
265
|
+
if (cmd === '' || cmd === 'sync') {
|
|
266
|
+
const d = await data(cmd === '');
|
|
267
|
+
const file = dashboard(d);
|
|
268
|
+
if (cmd === 'sync')
|
|
269
|
+
return;
|
|
270
|
+
const rows = applyFilter(d.rows, filter());
|
|
271
|
+
if (opt.json)
|
|
272
|
+
return out(JSON.stringify(summaryJson(rows), null, 2));
|
|
273
|
+
out('\n' + (rows.length ? summary(rows) : L().noData));
|
|
274
|
+
out('\n' + fill(L().dashboard, { file }));
|
|
275
|
+
return footer(d);
|
|
276
|
+
}
|
|
277
|
+
if (PERIODS.includes(cmd) || DIMENSIONS.includes(cmd) || ['insights', 'plan', 'blocks'].includes(cmd)) {
|
|
278
|
+
const d = await data(false);
|
|
279
|
+
const f = filter();
|
|
280
|
+
const rows = applyFilter(d.rows, f);
|
|
281
|
+
const limits = limitsInRange(d.limits, settings.timezone, f.since, f.until, machineMatch(f));
|
|
282
|
+
const limit = count('--limit', opt.limit) ?? (['sessions', 'projects', 'blocks'].includes(cmd) ? 20 : undefined);
|
|
283
|
+
if (PERIODS.includes(cmd)) {
|
|
284
|
+
const p = cmd;
|
|
285
|
+
emit(periodReport(rows, p, { breakdown: opt.breakdown, flat: opt.csv }), periodJson(rows, p, opt.breakdown), !rows.length);
|
|
286
|
+
}
|
|
287
|
+
else if (DIMENSIONS.includes(cmd)) {
|
|
288
|
+
const dim = cmd;
|
|
289
|
+
emit(dimensionReport(rows, dim, limit), dimensionJson(rows, dim, limit), !rows.length);
|
|
290
|
+
}
|
|
291
|
+
else if (cmd === 'plan') {
|
|
292
|
+
const p = planReport(rows, limits, { timezone: settings.timezone, plan: config.plan });
|
|
293
|
+
emit(p.report, p.json, !rows.length);
|
|
294
|
+
if (!opt.json && !opt.csv && rows.length)
|
|
295
|
+
out('\n' + p.notes.join('\n'));
|
|
296
|
+
}
|
|
297
|
+
else if (cmd === 'blocks') {
|
|
298
|
+
if (!hourFilterable(f))
|
|
299
|
+
console.error(L().hourFilters);
|
|
300
|
+
const hours = applyHourFilter(d.hours, f);
|
|
301
|
+
const report = blocksReport(hours, limits, { timezone: settings.timezone, limit });
|
|
302
|
+
emit(report, report.rows.map((r) => Object.fromEntries(report.cols.map((c, i) => [c.title, r[i]]))), !hours.length);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
const hours = hourFilterable(f) ? applyHourFilter(d.hours, f) : undefined;
|
|
306
|
+
const r = insights({ rows, hours, limits, table: d.prices.table, timezone: settings.timezone });
|
|
307
|
+
out(opt.json ? JSON.stringify(r.json, null, 2) : rows.length ? r.text : L().noData);
|
|
308
|
+
}
|
|
309
|
+
if (!opt.json && !opt.csv)
|
|
310
|
+
footer(d);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (cmd === 'report') {
|
|
314
|
+
const file = dashboard(await data(false));
|
|
315
|
+
out(fill(L().dashboard, { file }));
|
|
316
|
+
if (opt.open)
|
|
317
|
+
openFile(file);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (cmd === 'pricing') {
|
|
321
|
+
const { table: t, note } = await loadPrices(pricesCache(), { offline: opt.offline, refresh: opt.refresh });
|
|
322
|
+
if (opt.json)
|
|
323
|
+
return out(JSON.stringify(t, null, 2));
|
|
324
|
+
const c = L().col;
|
|
325
|
+
const models = Object.entries(t.models).map(([m, p]) => [m, p.join(' / ')]);
|
|
326
|
+
const fast = Object.entries(t.fast).map(([m, p]) => [m + '-fast', p.map((v) => +v.toFixed(4)).join(' / ')]);
|
|
327
|
+
out(table([c.model, c.price], [...models, ...fast], { left: [1] }));
|
|
328
|
+
const date = t.date.slice(0, 10);
|
|
329
|
+
out('\n' + (note ? fill(L().pricesFallback, { reason: note, source: t.source, date }) : fill(L().pricesLive, { source: t.source, date })));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (cmd === 'setup')
|
|
333
|
+
return setup();
|
|
334
|
+
if (cmd === 'status')
|
|
335
|
+
return status();
|
|
336
|
+
if (cmd === 'config') {
|
|
337
|
+
if (rest[0] === 'set') {
|
|
338
|
+
const [key, value = ''] = rest.slice(1);
|
|
339
|
+
if (!CONFIG_KEYS.includes(key))
|
|
340
|
+
fail(fill(L().configUnknownKey, { key: String(key), keys: CONFIG_KEYS.join(', ') }));
|
|
341
|
+
const next = { ...config };
|
|
342
|
+
next[key] = key === 'anonymizeProjects' ? value === 'true' : key === 'syncDir' ? path.resolve(value) : value;
|
|
343
|
+
saveConfig(next);
|
|
344
|
+
return out(fill(L().configSet, { key: key, value: String(next[key]) }));
|
|
345
|
+
}
|
|
346
|
+
out(fill(L().configFile, { file: configFile() }));
|
|
347
|
+
return out(JSON.stringify({ ...settings, languages: Object.keys(LANGS) }, null, 2));
|
|
348
|
+
}
|
|
349
|
+
fail(fill(L().unknownCommand, { cmd }));
|
|
350
|
+
}
|
|
351
|
+
await main();
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { configDir } from "./paths.js";
|
|
4
|
+
// Monthly subscription price in USD
|
|
5
|
+
export const PLANS = { pro: 20, max5x: 100, max20x: 200 };
|
|
6
|
+
export const CONFIG_KEYS = ['syncDir', 'machine', 'lang', 'timezone', 'plan', 'anonymizeProjects'];
|
|
7
|
+
export const configFile = () => path.join(configDir(), 'config.json');
|
|
8
|
+
export function loadConfig() {
|
|
9
|
+
const file = configFile();
|
|
10
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
|
11
|
+
}
|
|
12
|
+
export function saveConfig(config) {
|
|
13
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
14
|
+
fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + '\n');
|
|
15
|
+
}
|