@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 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
+ }