cc-logbook 0.0.0-stage → 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 +47 -0
- package/README.md +130 -2
- package/bin/cc-logbook.js +636 -0
- package/package.json +33 -4
- package/site/index.html +566 -0
- package/src/aggregate.js +140 -0
- package/src/aliases.js +30 -0
- package/src/auth.js +115 -0
- package/src/config.js +46 -0
- package/src/cron.js +53 -0
- package/src/git.js +66 -0
- package/src/parse.js +101 -0
- package/src/pricing.js +92 -0
- package/src/serve.js +82 -0
- package/src/sources.js +112 -0
- package/src/store.js +92 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { findConfigDirs, listTranscripts } from "../src/sources.js";
|
|
8
|
+
import { parseTranscripts } from "../src/parse.js";
|
|
9
|
+
import { mergeRows } from "../src/store.js";
|
|
10
|
+
import { aggregate, buildSite, systemTimezone } from "../src/aggregate.js";
|
|
11
|
+
import { priceRow } from "../src/pricing.js";
|
|
12
|
+
import { startServer, canOpenBrowser, openBrowser } from "../src/serve.js";
|
|
13
|
+
import * as G from "../src/git.js";
|
|
14
|
+
import * as A from "../src/auth.js";
|
|
15
|
+
import { cronLine, installCron, removeCron, SCHEDULES } from "../src/cron.js";
|
|
16
|
+
import { paths, loadConfig, saveConfig, removeConfig, machineLabel, sanitizeMachine } from "../src/config.js";
|
|
17
|
+
|
|
18
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
19
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
20
|
+
|
|
21
|
+
const HELP = `cc-logbook ${VERSION} — Claude Code token usage dashboard
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
cc-logbook local dashboard on http://localhost:4545 (reads ~/.claude, writes nothing)
|
|
25
|
+
cc-logbook preview print totals in the terminal
|
|
26
|
+
cc-logbook init set up sync mode on this machine (private GitHub repo + hourly push)
|
|
27
|
+
cc-logbook push export + commit + push (what cron runs)
|
|
28
|
+
cc-logbook build aggregate data/**/*.jsonl → site/ (run inside a data repo)
|
|
29
|
+
cc-logbook uninstall remove cron entry and config (keeps the data repo)
|
|
30
|
+
|
|
31
|
+
Common flags:
|
|
32
|
+
--dir [label=]path Claude config dir to read (repeatable)
|
|
33
|
+
--machine <name> machine label (default: $CC_LOGBOOK_MACHINE, config, hostname)
|
|
34
|
+
--include <prefix> only read projects whose alias starts with prefix (repeatable)
|
|
35
|
+
--days <n> only read the last n days
|
|
36
|
+
--verbose print git commands and file details
|
|
37
|
+
|
|
38
|
+
Local mode: --port <n> (default 4545) --no-open
|
|
39
|
+
push: --dry-run
|
|
40
|
+
build: --root <data repo dir> (default: current dir)
|
|
41
|
+
init: --repo <url> (use existing repo) --create --name <repo> (default cc-logbook-data)
|
|
42
|
+
--timezone <IANA> --schedule hourly|daily|none|"<cron>" --repo-path <dir> --yes
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
class UsageError extends Error {}
|
|
46
|
+
|
|
47
|
+
const REPEATABLE = new Set(["dir", "include"]);
|
|
48
|
+
const VALUED = new Set(["dir", "include", "machine", "days", "port", "repo", "repo-path", "schedule", "name", "timezone", "root"]);
|
|
49
|
+
const BOOLEAN = new Set(["dry-run", "verbose", "yes", "help", "version", "no-open", "create"]);
|
|
50
|
+
const COMMANDS = new Set(["preview", "init", "push", "build", "uninstall"]);
|
|
51
|
+
|
|
52
|
+
export function parseArgs(argv) {
|
|
53
|
+
const flags = { dir: [], include: [] };
|
|
54
|
+
const positional = [];
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const a = argv[i];
|
|
57
|
+
if (a === "-h") {
|
|
58
|
+
flags.help = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (a === "-v") {
|
|
62
|
+
flags.version = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!a.startsWith("--")) {
|
|
66
|
+
positional.push(a);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
let [name, value] = a.slice(2).split(/=(.*)/s, 2);
|
|
70
|
+
if (BOOLEAN.has(name)) {
|
|
71
|
+
flags[name] = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!VALUED.has(name)) throw new UsageError(`unknown flag --${name}`);
|
|
75
|
+
if (value === undefined) {
|
|
76
|
+
value = argv[++i];
|
|
77
|
+
if (value === undefined || value.startsWith("--")) throw new UsageError(`--${name} needs a value`);
|
|
78
|
+
}
|
|
79
|
+
if (REPEATABLE.has(name)) flags[name].push(value);
|
|
80
|
+
else flags[name] = value;
|
|
81
|
+
}
|
|
82
|
+
for (const n of ["days", "port"]) {
|
|
83
|
+
if (flags[n] !== undefined && !(Number.isInteger(Number(flags[n])) && Number(flags[n]) > 0)) {
|
|
84
|
+
throw new UsageError(`--${n} must be a positive integer`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { command: positional[0], rest: positional.slice(1), flags };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fmt = (n) =>
|
|
91
|
+
n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : String(n);
|
|
92
|
+
const fmtCost = (c, known = true) => `$${c.toFixed(2)}${known ? "" : "+"}`;
|
|
93
|
+
|
|
94
|
+
/** Parse local logs per flags + config. */
|
|
95
|
+
function collect(flags, cfg) {
|
|
96
|
+
const dirs = flags.dir.length ? flags.dir : cfg.dirs || [];
|
|
97
|
+
const include = flags.include.length ? flags.include : cfg.include || [];
|
|
98
|
+
const configDirs = findConfigDirs({ dirs });
|
|
99
|
+
const files = configDirs.flatMap(listTranscripts);
|
|
100
|
+
if (flags.verbose) {
|
|
101
|
+
for (const d of configDirs) console.error(`config dir [${d.label}] ${d.path}`);
|
|
102
|
+
console.error(`${files.length} transcript files`);
|
|
103
|
+
}
|
|
104
|
+
let { rows, scanned } = parseTranscripts(files, { include });
|
|
105
|
+
if (flags.days) {
|
|
106
|
+
const since = new Date(Date.now() - Number(flags.days) * 86400e3).toISOString();
|
|
107
|
+
rows = rows.filter((r) => new Date(r.t).toISOString() >= since);
|
|
108
|
+
}
|
|
109
|
+
return { configDirs, rows, scanned };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------- local mode ----------
|
|
113
|
+
|
|
114
|
+
async function local(flags) {
|
|
115
|
+
const cfg = loadConfig();
|
|
116
|
+
const machine = machineLabel({ flag: flags.machine, config: cfg });
|
|
117
|
+
const timezone = systemTimezone();
|
|
118
|
+
let last = null;
|
|
119
|
+
const load = () => {
|
|
120
|
+
const { configDirs, rows } = collect(flags, cfg);
|
|
121
|
+
last = { configDirs, rows };
|
|
122
|
+
return aggregate(
|
|
123
|
+
rows.map((row) => ({ machine, row })),
|
|
124
|
+
{ timezone, aliases: cfg.aliases, mode: "local", title: "Claude Code usage" }
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
const { url } = await startServer({ load, port: Number(flags.port || 4545) });
|
|
128
|
+
if (!last.configDirs.length) console.log("No Claude Code sessions found in ~/.claude/projects (use --dir to point elsewhere).");
|
|
129
|
+
else console.log(`${last.rows.length} responses from ${last.configDirs.map((d) => d.label).join(", ")}.`);
|
|
130
|
+
console.log(`Dashboard: ${url} (Ctrl+C to stop)`);
|
|
131
|
+
if (!flags["no-open"] && canOpenBrowser()) openBrowser(url);
|
|
132
|
+
else if (!flags["no-open"]) {
|
|
133
|
+
const port = new URL(url).port;
|
|
134
|
+
console.log(
|
|
135
|
+
`On a remote machine? In VS Code Remote-SSH this port is forwarded automatically; otherwise use \`ssh -L ${port}:localhost:${port} <host>\`.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return new Promise(() => {}); // run until interrupted
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------- preview ----------
|
|
142
|
+
|
|
143
|
+
function localDate(t) {
|
|
144
|
+
const d = new Date(t);
|
|
145
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function preview(flags) {
|
|
149
|
+
const cfg = loadConfig();
|
|
150
|
+
const { configDirs, rows, scanned } = collect(flags, cfg);
|
|
151
|
+
if (!configDirs.length) {
|
|
152
|
+
console.log("No Claude Code sessions found in ~/.claude/projects (use --dir to point elsewhere).");
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
const today = new Date();
|
|
156
|
+
const dayStart = (back) => localDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() - back));
|
|
157
|
+
const windows = [
|
|
158
|
+
["Today", dayStart(0)],
|
|
159
|
+
["Last 7 days", dayStart(6)],
|
|
160
|
+
["Last 30 days", dayStart(29)],
|
|
161
|
+
];
|
|
162
|
+
const sum = (rs) => {
|
|
163
|
+
const t = { in: 0, cw: 0, cr: 0, out: 0, tokens: 0, cost: 0, known: true };
|
|
164
|
+
for (const r of rs) {
|
|
165
|
+
for (const f of ["in", "cw", "cr", "out"]) t[f] += r[f];
|
|
166
|
+
const c = priceRow(r);
|
|
167
|
+
if (c === null) t.known = false;
|
|
168
|
+
else t.cost += c;
|
|
169
|
+
}
|
|
170
|
+
t.tokens = t.in + t.cw + t.cr + t.out;
|
|
171
|
+
return t;
|
|
172
|
+
};
|
|
173
|
+
const line = (name, t) =>
|
|
174
|
+
` ${name.padEnd(28).slice(0, 28)} ${fmt(t.in).padStart(8)} ${fmt(t.cw).padStart(9)} ${fmt(t.cr).padStart(9)} ${fmt(t.out).padStart(9)} ${fmtCost(t.cost, t.known).padStart(10)}`;
|
|
175
|
+
console.log(`${rows.length} responses (${scanned} usage rows scanned) from ${configDirs.map((d) => d.label).join(", ")}`);
|
|
176
|
+
console.log("Cost is an API-equivalent estimate, not your bill.\n");
|
|
177
|
+
for (const [title, from] of windows) {
|
|
178
|
+
const inWin = rows.filter((r) => localDate(r.t) >= from);
|
|
179
|
+
const total = sum(inWin);
|
|
180
|
+
console.log(`${title}: ${fmt(total.tokens)} tokens, ${fmtCost(total.cost, total.known)}, ${inWin.length} responses`);
|
|
181
|
+
if (!inWin.length) {
|
|
182
|
+
console.log("");
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const byModel = new Map();
|
|
186
|
+
for (const r of inWin) byModel.set(r.m, [...(byModel.get(r.m) || []), r]);
|
|
187
|
+
console.log(" model input c.write c.read output cost");
|
|
188
|
+
for (const [m, rs] of [...byModel].map(([m, rs]) => [m, sum(rs)]).sort((a, b) => b[1].tokens - a[1].tokens)) console.log(line(m, rs));
|
|
189
|
+
if (byModel.size > 1) console.log(line("total", total));
|
|
190
|
+
const byProject = new Map();
|
|
191
|
+
for (const r of inWin) byProject.set(r.p, [...(byProject.get(r.p) || []), r]);
|
|
192
|
+
console.log(" top projects:");
|
|
193
|
+
for (const [p, t] of [...byProject].map(([p, rs]) => [p, sum(rs)]).sort((a, b) => b[1].tokens - a[1].tokens).slice(0, 5)) {
|
|
194
|
+
console.log(` ${p.padEnd(40).slice(0, 40)} ${fmt(t.tokens).padStart(9)} ${fmtCost(t.cost, t.known).padStart(10)}`);
|
|
195
|
+
}
|
|
196
|
+
console.log("");
|
|
197
|
+
}
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------- build ----------
|
|
202
|
+
|
|
203
|
+
function build(flags) {
|
|
204
|
+
const root = path.resolve(flags.root || process.cwd());
|
|
205
|
+
if (!fs.existsSync(path.join(root, "data")) && !fs.existsSync(path.join(root, "cc-logbook.config.json"))) {
|
|
206
|
+
console.error(`${root} doesn't look like a cc-logbook data repo (no data/ or cc-logbook.config.json).`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
const { data, entries, out } = buildSite(root);
|
|
210
|
+
console.log(
|
|
211
|
+
`built ${path.relative(process.cwd(), out) || "."}: ${entries} rows from ${data.machines.length} machines → ${data.days.length} day buckets` +
|
|
212
|
+
(data.unpricedModels.length ? `; unpriced models: ${data.unpricedModels.join(", ")}` : "")
|
|
213
|
+
);
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------- push ----------
|
|
218
|
+
|
|
219
|
+
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
220
|
+
|
|
221
|
+
function acquireLock(file) {
|
|
222
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
223
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
const fd = fs.openSync(file, "wx");
|
|
226
|
+
fs.writeSync(fd, String(process.pid));
|
|
227
|
+
fs.closeSync(fd);
|
|
228
|
+
let released = false;
|
|
229
|
+
const release = () => {
|
|
230
|
+
if (released) return;
|
|
231
|
+
released = true;
|
|
232
|
+
try {
|
|
233
|
+
fs.rmSync(file);
|
|
234
|
+
} catch {}
|
|
235
|
+
};
|
|
236
|
+
process.on("exit", release);
|
|
237
|
+
return release;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
if (e.code !== "EEXIST") throw e;
|
|
240
|
+
try {
|
|
241
|
+
if (Date.now() - fs.statSync(file).mtimeMs > LOCK_STALE_MS) {
|
|
242
|
+
fs.rmSync(file);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function repoPathOf(flags, cfg) {
|
|
255
|
+
return path.resolve(flags["repo-path"] || cfg.repoPath || paths().defaultRepoPath);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function push(flags) {
|
|
259
|
+
const cfg = loadConfig();
|
|
260
|
+
const machine = machineLabel({ flag: flags.machine, config: cfg });
|
|
261
|
+
const repoPath = repoPathOf(flags, cfg);
|
|
262
|
+
const opts = { verbose: flags.verbose };
|
|
263
|
+
if (!fs.existsSync(path.join(repoPath, ".git"))) {
|
|
264
|
+
console.error(`No data repo at ${repoPath}. Run \`cc-logbook init\` first.`);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
const release = acquireLock(path.join(paths().stateDir, "push.lock"));
|
|
268
|
+
if (!release) {
|
|
269
|
+
if (flags.verbose) console.error("another push is running; exiting");
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
if (!flags["dry-run"]) {
|
|
274
|
+
const pulled = G.pullRebase(repoPath, opts);
|
|
275
|
+
if (!pulled.ok) {
|
|
276
|
+
G.abortRebase(repoPath);
|
|
277
|
+
console.error(`git pull --rebase failed in ${repoPath}:\n${pulled.stderr.trim()}`);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const { rows, scanned } = collect(flags, cfg);
|
|
282
|
+
const res = mergeRows(repoPath, machine, rows, { dryRun: flags["dry-run"] });
|
|
283
|
+
const summary = `[${machine}] ${scanned} rows scanned, ${res.added} new, ${res.updated} updated, months touched: ${
|
|
284
|
+
res.months.join(" ") || "none"
|
|
285
|
+
}`;
|
|
286
|
+
if (flags["dry-run"] || !res.months.length) {
|
|
287
|
+
console.log(summary + (flags["dry-run"] ? " (dry run)" : ""));
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
const pathspec = `data/${machine}`;
|
|
291
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
292
|
+
if (!G.commitPath(repoPath, pathspec, `data(${machine}): ${res.added} new rows, ${date}`, opts)) {
|
|
293
|
+
console.log(summary);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
let pushed = G.push(repoPath, opts);
|
|
297
|
+
if (!pushed.ok) {
|
|
298
|
+
const retry = G.pullRebase(repoPath, opts);
|
|
299
|
+
if (!retry.ok) {
|
|
300
|
+
G.abortRebase(repoPath);
|
|
301
|
+
console.error(
|
|
302
|
+
`Push rejected and rebase hit a conflict. Each machine should only write data/${machine}; ` +
|
|
303
|
+
`check that no other machine uses the label "${machine}".\n${retry.stderr.trim()}`
|
|
304
|
+
);
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
pushed = G.push(repoPath, opts);
|
|
308
|
+
if (!pushed.ok) {
|
|
309
|
+
console.error(`git push failed:\n${pushed.stderr.trim()}`);
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
console.log(summary);
|
|
314
|
+
return 0;
|
|
315
|
+
} finally {
|
|
316
|
+
release();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------- init ----------
|
|
321
|
+
|
|
322
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
323
|
+
|
|
324
|
+
function schedulerHelp(nodePath, scriptPath, schedule) {
|
|
325
|
+
if (process.platform === "darwin") {
|
|
326
|
+
const interval =
|
|
327
|
+
schedule === "daily"
|
|
328
|
+
? "<key>StartCalendarInterval</key><dict><key>Hour</key><integer>21</integer><key>Minute</key><integer>17</integer></dict>"
|
|
329
|
+
: "<key>StartInterval</key><integer>3600</integer>";
|
|
330
|
+
return `
|
|
331
|
+
macOS alternative to cron: save as ~/Library/LaunchAgents/dev.cc-logbook.push.plist
|
|
332
|
+
|
|
333
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
334
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
335
|
+
<plist version="1.0"><dict>
|
|
336
|
+
<key>Label</key><string>dev.cc-logbook.push</string>
|
|
337
|
+
<key>ProgramArguments</key><array><string>${nodePath}</string><string>${scriptPath}</string><string>push</string></array>
|
|
338
|
+
${interval}
|
|
339
|
+
</dict></plist>
|
|
340
|
+
|
|
341
|
+
then: launchctl load ~/Library/LaunchAgents/dev.cc-logbook.push.plist (and remove the cron line with \`crontab -e\`).
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
if (process.platform === "win32") {
|
|
345
|
+
const trigger = schedule === "daily" ? "/sc daily /st 21:17" : "/sc hourly /mo 1";
|
|
346
|
+
return `
|
|
347
|
+
Windows has no cron. Schedule the push with Task Scheduler (not automated):
|
|
348
|
+
|
|
349
|
+
schtasks /create /tn cc-logbook-push ${trigger} /tr "\\"${nodePath}\\" \\"${scriptPath}\\" push"
|
|
350
|
+
|
|
351
|
+
Remove it later with: schtasks /delete /tn cc-logbook-push /f
|
|
352
|
+
`;
|
|
353
|
+
}
|
|
354
|
+
return "";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Path to a globally installed cc-logbook, or null. */
|
|
358
|
+
function globalInstallPath() {
|
|
359
|
+
const r = spawnSync("npm", ["root", "-g"], { encoding: "utf8", shell: process.platform === "win32" });
|
|
360
|
+
if (r.status !== 0) return null;
|
|
361
|
+
const p = path.join(r.stdout.trim(), "cc-logbook", "bin", "cc-logbook.js");
|
|
362
|
+
return fs.existsSync(p) ? fs.realpathSync(p) : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A script path that will still exist when cron runs. Offers `npm i -g` when running from the npx cache. */
|
|
366
|
+
async function stableScriptPath(ask, flags) {
|
|
367
|
+
const self = fs.realpathSync(SELF);
|
|
368
|
+
if (!/[\\/]_npx[\\/]/.test(self)) return self;
|
|
369
|
+
const existing = globalInstallPath();
|
|
370
|
+
if (existing) return existing;
|
|
371
|
+
console.log("\nYou're running cc-logbook through npx, whose cache can be cleared, which would break the scheduled push.");
|
|
372
|
+
const yes = flags.yes || /^y/i.test(await ask("Install it globally now with `npm i -g cc-logbook`? [Y/n]", "y"));
|
|
373
|
+
if (yes) {
|
|
374
|
+
const r = spawnSync("npm", ["install", "-g", `cc-logbook@${VERSION}`], { stdio: "inherit", shell: process.platform === "win32" });
|
|
375
|
+
const installed = r.status === 0 ? globalInstallPath() : null;
|
|
376
|
+
if (installed) return installed;
|
|
377
|
+
console.warn("Global install failed (you may need sudo or a user-level npm prefix).");
|
|
378
|
+
}
|
|
379
|
+
console.warn("Skipping the scheduled push. Run `npm i -g cc-logbook && cc-logbook init` later to add it.");
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function ensureGhLogin(ask) {
|
|
384
|
+
if (!A.hasGh()) {
|
|
385
|
+
console.log(`\nThe GitHub CLI (gh) isn't installed. Install it with:\n\n ${A.ghInstallHint()}\n`);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
if (!A.ghLoggedIn()) {
|
|
389
|
+
console.log("\nLog in to GitHub. You'll get a one-time code: open github.com/login/device on any device and enter it.");
|
|
390
|
+
await ask("Press Enter to continue", "");
|
|
391
|
+
if (!A.ghLogin()) return false;
|
|
392
|
+
} else {
|
|
393
|
+
A.ghSetupGit();
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function init(flags) {
|
|
399
|
+
const cfg = loadConfig();
|
|
400
|
+
const interactive = !flags.yes && process.stdin.isTTY;
|
|
401
|
+
const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
402
|
+
const ask = async (q, def) => {
|
|
403
|
+
if (!rl) return def;
|
|
404
|
+
const a = (await rl.question(def ? `${q} [${def}]: ` : `${q}: `)).trim();
|
|
405
|
+
return a || def;
|
|
406
|
+
};
|
|
407
|
+
const opts = { verbose: flags.verbose };
|
|
408
|
+
try {
|
|
409
|
+
// 1. git
|
|
410
|
+
if (!G.hasGit()) {
|
|
411
|
+
console.error("git not found on PATH. Install git and re-run `cc-logbook init`.");
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
414
|
+
const repoPath = repoPathOf(flags, cfg);
|
|
415
|
+
|
|
416
|
+
// 2. existing or create
|
|
417
|
+
let repoUrl = flags.repo || null;
|
|
418
|
+
let create = Boolean(flags.create);
|
|
419
|
+
if (!repoUrl && !create) {
|
|
420
|
+
if (cfg.repoUrl && fs.existsSync(path.join(repoPath, ".git"))) repoUrl = cfg.repoUrl;
|
|
421
|
+
else {
|
|
422
|
+
const choice = await ask("Create a new private data repo, or use an existing one? (create/existing)", "create");
|
|
423
|
+
if (/^e/i.test(choice)) repoUrl = await ask("Data repo URL (HTTPS or SSH)");
|
|
424
|
+
else create = true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!create && !repoUrl) throw new UsageError("a data repo URL is required (--repo <url>), or use --create");
|
|
428
|
+
|
|
429
|
+
let slug = repoUrl ? A.repoSlug(repoUrl) : null;
|
|
430
|
+
let created = false;
|
|
431
|
+
|
|
432
|
+
// 3. auth ladder
|
|
433
|
+
if (create) {
|
|
434
|
+
if (!(await ensureGhLogin(ask))) {
|
|
435
|
+
console.log(
|
|
436
|
+
`Creating a repo needs the GitHub CLI. Alternatively create one yourself from https://github.com/${A.TEMPLATE_REPO} ` +
|
|
437
|
+
`("Use this template", private) and run: cc-logbook init --repo <url>`
|
|
438
|
+
);
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
} else if (A.hasGitAccess(repoUrl)) {
|
|
442
|
+
if (flags.verbose) console.log("Existing git access works.");
|
|
443
|
+
} else {
|
|
444
|
+
console.log(`Can't access ${repoUrl} with your current git credentials.`);
|
|
445
|
+
let ok = false;
|
|
446
|
+
if (await ensureGhLogin(ask)) {
|
|
447
|
+
ok = A.hasGitAccess(repoUrl);
|
|
448
|
+
if (!ok && /^git@|^ssh:/.test(repoUrl)) {
|
|
449
|
+
console.log(`gh login covers HTTPS URLs. Try: cc-logbook init --repo https://github.com/${slug}.git`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (!ok) {
|
|
453
|
+
console.log(A.deployKeyHelp(repoUrl));
|
|
454
|
+
return 1;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 4. create the repo
|
|
459
|
+
if (create) {
|
|
460
|
+
const name = flags.name || (await ask("Name for the new private repo", "cc-logbook-data"));
|
|
461
|
+
const owner = A.ghUser();
|
|
462
|
+
slug = name.includes("/") ? name : `${owner}/${name}`;
|
|
463
|
+
console.log(`Creating private repo ${slug} from ${A.TEMPLATE_REPO}…`);
|
|
464
|
+
const r = A.ghCreateRepo(slug);
|
|
465
|
+
if (!r.ok) {
|
|
466
|
+
console.error(`gh repo create failed: ${r.stderr}`);
|
|
467
|
+
return 1;
|
|
468
|
+
}
|
|
469
|
+
created = true;
|
|
470
|
+
repoUrl = A.ghRepoUrl(slug);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// clone or update
|
|
474
|
+
if (fs.existsSync(path.join(repoPath, ".git"))) {
|
|
475
|
+
const remote = G.git(["remote", "get-url", "origin"], { cwd: repoPath, allowFail: true }).stdout.trim();
|
|
476
|
+
if (remote && A.repoSlug(remote) !== A.repoSlug(repoUrl)) {
|
|
477
|
+
console.error(`${repoPath} already holds a clone of ${remote}. Pass --repo-path to use another location.`);
|
|
478
|
+
return 1;
|
|
479
|
+
}
|
|
480
|
+
console.log(`Updating existing clone at ${repoPath}`);
|
|
481
|
+
const r = G.pullRebase(repoPath, opts);
|
|
482
|
+
if (!r.ok) console.warn(`warning: git pull failed: ${r.stderr.trim()}`);
|
|
483
|
+
} else {
|
|
484
|
+
console.log(`Cloning ${repoUrl} → ${repoPath}`);
|
|
485
|
+
fs.mkdirSync(path.dirname(repoPath), { recursive: true });
|
|
486
|
+
G.clone(repoUrl, repoPath, opts);
|
|
487
|
+
}
|
|
488
|
+
// A repo generated from a template is filled in asynchronously; wait for the first commit.
|
|
489
|
+
for (let i = 0; created && !G.hasCommits(repoPath) && i < 10; i++) {
|
|
490
|
+
sleep(2000);
|
|
491
|
+
G.git(["pull"], { cwd: repoPath, allowFail: true });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (created) {
|
|
495
|
+
const timezone = flags.timezone || (await ask("Timezone for counting days", systemTimezone()));
|
|
496
|
+
const cfgFile = path.join(repoPath, "cc-logbook.config.json");
|
|
497
|
+
let repoCfg = {};
|
|
498
|
+
try {
|
|
499
|
+
repoCfg = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
|
|
500
|
+
} catch {}
|
|
501
|
+
repoCfg = { title: "My Claude Code usage", aliases: {}, ...repoCfg, timezone };
|
|
502
|
+
fs.writeFileSync(cfgFile, JSON.stringify(repoCfg, null, 2) + "\n");
|
|
503
|
+
if (G.commitPath(repoPath, "cc-logbook.config.json", `config: timezone ${timezone}`, opts)) {
|
|
504
|
+
const p = G.push(repoPath, opts);
|
|
505
|
+
if (!p.ok) console.warn(`warning: couldn't push config: ${p.stderr.trim()}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 5. hosting
|
|
509
|
+
const pages = A.ghEnablePages(slug);
|
|
510
|
+
if (pages.ok) {
|
|
511
|
+
A.ghRunWorkflow(slug);
|
|
512
|
+
console.log(`GitHub Pages enabled. Dashboard: ${pages.url || `https://${slug.split("/")[0]}.github.io/${slug.split("/")[1]}/`}`);
|
|
513
|
+
console.log("Note: Pages sites are publicly reachable even when the repo is private.");
|
|
514
|
+
} else {
|
|
515
|
+
if (flags.verbose) console.log(pages.reason);
|
|
516
|
+
console.log(A.cloudflareHelp(slug));
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
const check = G.hasCommits(repoPath) ? G.canPush(repoPath, opts) : { ok: true };
|
|
520
|
+
if (!check.ok) {
|
|
521
|
+
console.log("Read access works but push was refused.");
|
|
522
|
+
console.log(A.deployKeyHelp(repoUrl));
|
|
523
|
+
return 1;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 6. machine label
|
|
528
|
+
const machine = sanitizeMachine(flags.machine || (await ask("Label for this machine", machineLabel({ config: cfg }))));
|
|
529
|
+
const schedule = flags.schedule || cfg.schedule || "hourly";
|
|
530
|
+
const config = {
|
|
531
|
+
...cfg,
|
|
532
|
+
machine,
|
|
533
|
+
repoUrl,
|
|
534
|
+
repoPath,
|
|
535
|
+
dirs: flags.dir.length ? flags.dir : cfg.dirs || [],
|
|
536
|
+
include: flags.include.length ? flags.include : cfg.include || [],
|
|
537
|
+
schedule,
|
|
538
|
+
};
|
|
539
|
+
saveConfig(config);
|
|
540
|
+
|
|
541
|
+
// 7. schedule
|
|
542
|
+
const nodePath = process.execPath;
|
|
543
|
+
if (schedule !== "none") {
|
|
544
|
+
const scriptPath = await stableScriptPath(ask, flags);
|
|
545
|
+
if (scriptPath && process.platform !== "win32") {
|
|
546
|
+
const logFile = path.join(paths().stateDir, "push.log");
|
|
547
|
+
const line = cronLine({ schedule, nodePath, scriptPath, logFile });
|
|
548
|
+
fs.mkdirSync(paths().stateDir, { recursive: true });
|
|
549
|
+
try {
|
|
550
|
+
installCron(line);
|
|
551
|
+
console.log(`Installed cron (${SCHEDULES[schedule] ? schedule : "custom"}):\n ${line}`);
|
|
552
|
+
} catch (e) {
|
|
553
|
+
console.warn(`warning: could not install cron: ${e.message}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// 9. launchd / Task Scheduler
|
|
557
|
+
if (scriptPath) {
|
|
558
|
+
const help = schedulerHelp(nodePath, scriptPath, schedule);
|
|
559
|
+
if (help) console.log(help);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
rl?.close();
|
|
563
|
+
|
|
564
|
+
// 8. first push
|
|
565
|
+
console.log("Running first push…");
|
|
566
|
+
return push({ ...flags, machine, "repo-path": repoPath });
|
|
567
|
+
} finally {
|
|
568
|
+
rl?.close();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------- uninstall ----------
|
|
573
|
+
|
|
574
|
+
function uninstall(flags) {
|
|
575
|
+
const cfg = loadConfig();
|
|
576
|
+
if (process.platform !== "win32") {
|
|
577
|
+
try {
|
|
578
|
+
console.log(removeCron() ? "Removed cron entry." : "No cron entry found.");
|
|
579
|
+
} catch (e) {
|
|
580
|
+
console.warn(`warning: ${e.message}`);
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
console.log("On Windows, remove the scheduled task with: schtasks /delete /tn cc-logbook-push /f");
|
|
584
|
+
}
|
|
585
|
+
console.log(removeConfig() ? `Removed ${paths().configFile}.` : "No config file found.");
|
|
586
|
+
console.log(`Kept the data repo clone at ${repoPathOf(flags, cfg)} (delete it manually if you want).`);
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------- main ----------
|
|
591
|
+
|
|
592
|
+
export async function main(argv) {
|
|
593
|
+
let parsed;
|
|
594
|
+
try {
|
|
595
|
+
parsed = parseArgs(argv);
|
|
596
|
+
if (parsed.command && !COMMANDS.has(parsed.command)) throw new UsageError(`unknown command "${parsed.command}"`);
|
|
597
|
+
} catch (e) {
|
|
598
|
+
console.error(`cc-logbook: ${e.message}\n\n${HELP}`);
|
|
599
|
+
return 2;
|
|
600
|
+
}
|
|
601
|
+
const { command, flags } = parsed;
|
|
602
|
+
if (flags.version) {
|
|
603
|
+
console.log(VERSION);
|
|
604
|
+
return 0;
|
|
605
|
+
}
|
|
606
|
+
if (flags.help) {
|
|
607
|
+
console.log(HELP);
|
|
608
|
+
return 0;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
switch (command) {
|
|
612
|
+
case undefined:
|
|
613
|
+
return await local(flags);
|
|
614
|
+
case "preview":
|
|
615
|
+
return preview(flags);
|
|
616
|
+
case "init":
|
|
617
|
+
return await init(flags);
|
|
618
|
+
case "push":
|
|
619
|
+
return push(flags);
|
|
620
|
+
case "build":
|
|
621
|
+
return build(flags);
|
|
622
|
+
case "uninstall":
|
|
623
|
+
return uninstall(flags);
|
|
624
|
+
}
|
|
625
|
+
} catch (e) {
|
|
626
|
+
if (e instanceof UsageError) {
|
|
627
|
+
console.error(`cc-logbook: ${e.message}\n\n${HELP}`);
|
|
628
|
+
return 2;
|
|
629
|
+
}
|
|
630
|
+
console.error(`cc-logbook: ${flags.verbose ? e.stack : e.message}`);
|
|
631
|
+
return 1;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(SELF);
|
|
636
|
+
if (isMain) main(process.argv.slice(2)).then((code) => process.exit(code));
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-logbook",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dashboard of Claude Code token usage: local with zero setup, or synced across all your machines via a private GitHub repo",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-logbook": "bin/cc-logbook.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"site"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/the-silent-geek/Claude-code-usage.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/the-silent-geek/Claude-code-usage#readme",
|
|
25
|
+
"bugs": "https://github.com/the-silent-geek/Claude-code-usage/issues",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"claude",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"usage",
|
|
30
|
+
"tokens",
|
|
31
|
+
"cost",
|
|
32
|
+
"dashboard"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|