tokentown 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.
Files changed (3) hide show
  1. package/README.md +52 -0
  2. package/cli.js +1075 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # `npx tokentown`
2
+
3
+ Put your city on the [**TOKENTOWN**](https://tokentown-gamma.vercel.app) leaderboard from the terminal — **no app to install.**
4
+
5
+ TOKENTOWN turns your real Claude Code token usage into a pixel city: every token your agents burn raises a building. This little CLI reads your usage on your own machine and reports this season's numbers to the leaderboard, so you get a city at `/u/<name>` in about ten seconds.
6
+
7
+ ## Use it
8
+
9
+ ```bash
10
+ npx tokentown
11
+ ```
12
+
13
+ First run asks for a username, generates a private key, and saves a tiny config to `~/.tokentown-placar.json`. Every run after that just reports and prints your city URL.
14
+
15
+ ```
16
+ npx tokentown report your season once, print your city URL
17
+ npx tokentown watch keep running, report every ~10 minutes
18
+ npx tokentown --dry-run read & print exactly what WOULD be sent — nothing leaves your machine
19
+ npx tokentown --help
20
+ ```
21
+
22
+ Requires **Node 18+**. Zero dependencies.
23
+
24
+ ## What it reads
25
+
26
+ Your Claude Code session transcripts under `~/.claude/projects/**/*.jsonl` — token usage, tool calls and models — with the same per-message de-duplication and per-season backfill the desktop app uses, so the numbers are accurate whether or not you were running the app, and sub-agent usage is counted too.
27
+
28
+ - **Tokens → buildings** — `input + output + cache_creation` tokens.
29
+ - **Honest cost** — every field (including cache reads) at real per-model pricing.
30
+ - **Residents** — the sub-agents your sessions spawned.
31
+ - **Landmarks** — a waterfront garden at 100k tokens, a ferry at 300k, a lighthouse at 1M, a tower district at 3M.
32
+
33
+ ## Privacy
34
+
35
+ **Local-first.** Everything is read on your machine. Only your **username and the numbers** are ever sent — **never** prompts, code, conversation content, or project names.
36
+
37
+ Sharing your **setup** (the skills, MCP servers, tools and models you actually use — *names and counts only*) is **opt-in**: the first run asks, and you can flip `shareSetup` in `~/.tokentown-placar.json` anytime. Run `npx tokentown --dry-run` to see the exact payload before anything is sent.
38
+
39
+ ## Config
40
+
41
+ `~/.tokentown-placar.json` (override the path with `TOKENTOWN_CONFIG=/path/to.json` for testing). Shared with the desktop app — same file, same account. You can hand-edit a few cosmetics:
42
+
43
+ | field | what |
44
+ |---|---|
45
+ | `cityName` | your city's name on `/u/<name>` (≤ 24 chars) |
46
+ | `motto` | a line in italics under it (≤ 48 chars) |
47
+ | `accent` | `dourado` · `teal` · `rosa` · `violeta` · `verde` · `ambar` |
48
+ | `shareSetup` | `true`/`false` — share your stack |
49
+
50
+ ---
51
+
52
+ Part of the [TOKENTOWN](https://github.com/AElise08/tokentown) monorepo. Not affiliated with Anthropic.
package/cli.js ADDED
@@ -0,0 +1,1075 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // TOKENTOWN — `npx tokentown`
6
+ // Lightweight onboarding (no app to install): reads your REAL Claude Code token
7
+ // usage on this machine and reports the season's numbers to the leaderboard at
8
+ // https://tokentown-gamma.vercel.app. Only your username and the numbers are
9
+ // ever sent — never prompts, code, conversation content, or project names.
10
+ //
11
+ // Zero runtime dependencies. Node 18+ (global fetch, readline, crypto).
12
+ //
13
+ // The reading logic here is a standalone port of game/main.js (dedupe by
14
+ // message.id:requestId, per-season backfill by timestamp, tokens = input +
15
+ // output + cache_creation, honest cost via per-model pricing, subagents as
16
+ // residents, 7-day daily breakdown, "used-only" setup blob). The payload
17
+ // shaping mirrors client/placar.js (shapeCity / shapeSetup / shapeDailyTokens /
18
+ // shapeProfile) so the wire contract is identical to the desktop app's.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+ const os = require("os");
24
+ const crypto = require("crypto");
25
+ const readline = require("readline");
26
+
27
+ const PROJECTS = path.join(os.homedir(), ".claude", "projects");
28
+ const DEFAULT_URL = "https://tokentown-gamma.vercel.app/api/report";
29
+ const SITE_ORIGIN = "https://tokentown-gamma.vercel.app";
30
+
31
+ // Config path — overridable via env for testability (never touches the real
32
+ // ~/.tokentown-placar.json when TOKENTOWN_CONFIG points elsewhere).
33
+ function configPath() {
34
+ return process.env.TOKENTOWN_CONFIG || path.join(os.homedir(), ".tokentown-placar.json");
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // SEASONS — fixed 28-day windows on a global calendar (same formula as the app
39
+ // and the server; keep in sync). Epoch: 01/07/2026 00:00 UTC.
40
+ // ---------------------------------------------------------------------------
41
+ const SEASON_EPOCH = Date.UTC(2026, 6, 1);
42
+ const SEASON_MS = 28 * 86400000;
43
+ const TOK_PER_BUILD_REAL = 6000; // one building per ~6k real tokens
44
+ const ERA_STEP = 2000000; // era changes every ~2M tokens
45
+
46
+ function currentSeasonId(now) {
47
+ return Math.floor(((now || Date.now()) - SEASON_EPOCH) / SEASON_MS);
48
+ }
49
+ function daysLeftIn(now) {
50
+ now = now || Date.now();
51
+ const end = SEASON_EPOCH + (currentSeasonId(now) + 1) * SEASON_MS;
52
+ return Math.max(0, Math.ceil((end - now) / 86400000));
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // PRICING — USD per 1M tokens (source: claude-api skill). input = uncached
57
+ // input; output = generation. Cache multipliers over INPUT price: read = 0.10x,
58
+ // 5-min write = 1.25x, 1-hour write = 2.00x. Opus 4.8 keeps the standard table
59
+ // for its 1M window, so the "[1m]" suffix uses the same prices.
60
+ // ---------------------------------------------------------------------------
61
+ const PRICING = {
62
+ "claude-opus-4-8": { in: 5, out: 25 },
63
+ "claude-opus-4-7": { in: 5, out: 25 },
64
+ "claude-opus-4-6": { in: 5, out: 25 },
65
+ "claude-opus-4-5": { in: 5, out: 25 },
66
+ "claude-fable-5": { in: 10, out: 50 },
67
+ "claude-mythos-5": { in: 10, out: 50 },
68
+ "claude-sonnet-5": { in: 3, out: 15 },
69
+ "claude-sonnet-4-6": { in: 3, out: 15 },
70
+ "claude-sonnet-4-5": { in: 3, out: 15 },
71
+ "claude-haiku-4-5": { in: 1, out: 5 },
72
+ };
73
+ const SONNET_PRICE = { in: 3, out: 15 }; // unknown model -> approximate as Sonnet
74
+
75
+ function priceFor(model) {
76
+ if (!model) return SONNET_PRICE;
77
+ let m = String(model).toLowerCase();
78
+ if (m === "<synthetic>") return { in: 0, out: 0 }; // local message, no API cost
79
+ m = m.replace(/\[1m\]$/, ""); // drop long-context marker
80
+ m = m.replace(/-\d{8}$/, ""); // drop date suffix (e.g. -20251001)
81
+ if (PRICING[m]) return PRICING[m];
82
+ if (m === "opus" || m.startsWith("claude-opus")) return { in: 5, out: 25 };
83
+ if (m === "fable" || m.startsWith("claude-fable") || m.startsWith("claude-mythos")) return { in: 10, out: 50 };
84
+ if (m === "sonnet" || m.startsWith("claude-sonnet")) return { in: 3, out: 15 };
85
+ if (m === "haiku" || m.startsWith("claude-haiku")) return { in: 1, out: 5 };
86
+ return SONNET_PRICE;
87
+ }
88
+
89
+ // tokens that raise buildings: newly generated content (uncached input + output
90
+ // + newly written cache). Ignores cache_read (cheap, huge re-reads).
91
+ function tokensFromUsage(u) {
92
+ if (!u) return 0;
93
+ return (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0);
94
+ }
95
+
96
+ // honest USD cost of one usage line — every field (input, output, cache write,
97
+ // cache read) with real per-model pricing.
98
+ function costFromUsage(u, model) {
99
+ if (!u) return 0;
100
+ const p = priceFor(model);
101
+ if (!p.in && !p.out) return 0; // <synthetic>
102
+ const inTok = u.input_tokens || 0;
103
+ const outTok = u.output_tokens || 0;
104
+ const readTok = u.cache_read_input_tokens || 0;
105
+ const cc = u.cache_creation;
106
+ let w5 = 0,
107
+ w1 = 0; // cache write: 5-min vs 1-hour
108
+ if (cc && ((cc.ephemeral_1h_input_tokens || 0) + (cc.ephemeral_5m_input_tokens || 0)) > 0) {
109
+ w1 = cc.ephemeral_1h_input_tokens || 0;
110
+ w5 = cc.ephemeral_5m_input_tokens || 0;
111
+ } else {
112
+ w5 = u.cache_creation_input_tokens || 0; // no breakdown -> treat as 5-min (1.25x)
113
+ }
114
+ return (
115
+ (inTok * p.in + outTok * p.out + readTok * p.in * 0.1 + w5 * p.in * 1.25 + w1 * p.in * 2.0) / 1e6
116
+ );
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // DEDUPE — Claude Code writes the SAME assistant message across several lines
121
+ // (streaming/retry), each with identical usage. Counting every line doubles
122
+ // everything. Sets with a memory cap (FIFO eviction); duplicates are local
123
+ // (consecutive lines), so eviction never re-counts far-apart duplicates.
124
+ // ---------------------------------------------------------------------------
125
+ const USAGE_CAP = 5000;
126
+ const AGENT_CAP = 5000;
127
+ const TOOLS_CAP = 20000;
128
+
129
+ function remember(set, key, cap) {
130
+ if (set.has(key)) return false;
131
+ set.add(key);
132
+ if (set.size > cap) {
133
+ const first = set.values().next().value;
134
+ set.delete(first);
135
+ }
136
+ return true;
137
+ }
138
+
139
+ // subagents = tool_use blocks named "Agent" (or the older "Task"), deduped by
140
+ // the block id. Returns how many are NEW.
141
+ function countNewSubagents(o, seenAgents) {
142
+ const c = o && o.message && o.message.content;
143
+ let k = 0;
144
+ if (Array.isArray(c))
145
+ for (const b of c) {
146
+ if (b && b.type === "tool_use" && (b.name === "Agent" || b.name === "Task")) {
147
+ if (b.id != null) {
148
+ if (remember(seenAgents, b.id, AGENT_CAP)) k++;
149
+ } else k++;
150
+ }
151
+ }
152
+ return k;
153
+ }
154
+
155
+ function listJsonl(dir, acc) {
156
+ let ents;
157
+ try {
158
+ ents = fs.readdirSync(dir, { withFileTypes: true });
159
+ } catch (e) {
160
+ return acc;
161
+ }
162
+ for (const e of ents) {
163
+ const p = path.join(dir, e.name);
164
+ if (e.isDirectory()) listJsonl(p, acc);
165
+ else if (e.name.endsWith(".jsonl")) acc.push(p);
166
+ }
167
+ return acc;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // DAILY BREAKDOWN — bucketize city-tokens by UTC day over the last 7 UTC days.
172
+ // ---------------------------------------------------------------------------
173
+ const DAILY_WINDOW_DAYS = 7;
174
+ const DAY_MS = 86400000;
175
+
176
+ function utcDayKeyMs(ms) {
177
+ const d = new Date(ms);
178
+ const y = d.getUTCFullYear();
179
+ const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
180
+ const da = String(d.getUTCDate()).padStart(2, "0");
181
+ return "" + y + mo + da;
182
+ }
183
+ function utcMidnightMs(ms) {
184
+ const d = new Date(ms);
185
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
186
+ }
187
+ function dailyWindowStartMs(now) {
188
+ return utcMidnightMs(now) - (DAILY_WINDOW_DAYS - 1) * DAY_MS;
189
+ }
190
+ function dailyBucketize(entries, now) {
191
+ const startMs = dailyWindowStartMs(now);
192
+ const out = {};
193
+ if (!Array.isArray(entries)) return out;
194
+ for (const e of entries) {
195
+ if (!e || !Number.isFinite(e.ts)) continue;
196
+ if (e.ts < startMs) continue;
197
+ const k = utcDayKeyMs(e.ts);
198
+ out[k] = (out[k] || 0) + (Number(e.tokens) || 0);
199
+ }
200
+ return out;
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // SETUP → CITY — collect the LOCAL setup (names & counts only) for the opt-in
205
+ // "how this city was built" panel. NEVER prompts/code/content/paths — only
206
+ // names of skills/MCP/hooks and tool/model counts. Skills & MCP reflect what
207
+ // you REALLY USED this season (invocations in the transcripts), not what's
208
+ // merely installed. Ported from game/main.js.
209
+ // ---------------------------------------------------------------------------
210
+ const SETUP_V = 1;
211
+
212
+ function normModelSlug(model) {
213
+ if (!model) return null;
214
+ let s = String(model).toLowerCase();
215
+ if (s === "<synthetic>") return null;
216
+ s = s.replace(/\[1m\]$/, "").replace(/-\d{8}$/, "").replace(/^claude-/, "");
217
+ return s || null;
218
+ }
219
+
220
+ // modelTally: model -> city-tokens. Needs the usage tokens + usage dedupe.
221
+ function tallyForSetup(o, u, modelTally) {
222
+ const md = normModelSlug(o && o.message && o.message.model);
223
+ if (md) modelTally.set(md, (modelTally.get(md) || 0) + tokensFromUsage(u));
224
+ }
225
+
226
+ // tallyTools: count tool_use invocations for the setup (toolTally + skillTally),
227
+ // deduped by the tool_use block id (independent of the usage dedupe).
228
+ function tallyTools(o, seenTools, toolTally, skillTally) {
229
+ const c = o && o.message && o.message.content;
230
+ if (!Array.isArray(c)) return;
231
+ for (const b of c) {
232
+ if (!b || b.type !== "tool_use" || !b.name) continue;
233
+ if (b.id != null && !remember(seenTools, b.id, TOOLS_CAP)) continue;
234
+ toolTally.set(b.name, (toolTally.get(b.name) || 0) + 1);
235
+ if (b.name === "Skill" && b.input && b.input.skill) {
236
+ const sk = String(b.input.skill);
237
+ if (sk) skillTally.set(sk, (skillTally.get(sk) || 0) + 1);
238
+ }
239
+ }
240
+ }
241
+
242
+ function setupSlugStrict(s) {
243
+ return String(s)
244
+ .toLowerCase()
245
+ .replace(/[^a-z0-9-]+/g, "-")
246
+ .replace(/-{2,}/g, "-")
247
+ .replace(/^-+|-+$/g, "")
248
+ .slice(0, 40);
249
+ }
250
+ function setupToolName(s) {
251
+ return String(s).replace(/[^A-Za-z0-9_.-]+/g, "").slice(0, 48);
252
+ }
253
+ function uniq(a) {
254
+ const seen = new Set(),
255
+ out = [];
256
+ for (const x of a) if (x && !seen.has(x)) { seen.add(x); out.push(x); }
257
+ return out;
258
+ }
259
+ function readJsonSafe(p) {
260
+ try {
261
+ return JSON.parse(fs.readFileSync(p, "utf8"));
262
+ } catch (e) {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ // collectSetup — { v, skills, mcp, hooks, tools, models }. skills/mcp reflect
268
+ // what was really USED this season; hooks come from ~/.claude/settings.json.
269
+ function collectSetup(opts) {
270
+ opts = opts || {};
271
+ const home = opts.home || os.homedir();
272
+ const settingsJson = "settingsJson" in opts ? opts.settingsJson : path.join(home, ".claude", "settings.json");
273
+ const tools = opts.toolTally || new Map();
274
+ const models = opts.modelTally || new Map();
275
+ const skillsUsed = opts.skillTally || new Map();
276
+
277
+ const skills = uniq(
278
+ Array.from(skillsUsed.entries())
279
+ .filter((e) => e[1] > 0)
280
+ .sort((a, b) => b[1] - a[1])
281
+ .map((e) => setupSlugStrict(e[0]))
282
+ .filter(Boolean)
283
+ ).slice(0, 40);
284
+
285
+ const mcpCounts = new Map();
286
+ for (const e of tools.entries()) {
287
+ const mm = /^mcp__(.+?)__/.exec(String(e[0]));
288
+ if (mm) mcpCounts.set(mm[1], (mcpCounts.get(mm[1]) || 0) + Math.max(0, Number(e[1]) || 0));
289
+ }
290
+ const mcp = uniq(
291
+ Array.from(mcpCounts.entries())
292
+ .filter((e) => e[1] > 0)
293
+ .sort((a, b) => b[1] - a[1])
294
+ .map((e) => setupSlugStrict(e[0]))
295
+ .filter(Boolean)
296
+ ).slice(0, 20);
297
+
298
+ let hooks = [];
299
+ const sj = settingsJson ? readJsonSafe(settingsJson) : null;
300
+ if (sj && sj.hooks && typeof sj.hooks === "object") hooks = uniq(Object.keys(sj.hooks).map(setupSlugStrict)).slice(0, 12);
301
+
302
+ const toolsArr = Array.from(tools.entries())
303
+ .map((e) => [setupToolName(e[0]), Math.max(0, Math.floor(Number(e[1]) || 0))])
304
+ .filter((p) => p[0] && p[1] > 0)
305
+ .sort((a, b) => b[1] - a[1])
306
+ .slice(0, 10);
307
+
308
+ let total = 0;
309
+ for (const v of models.values()) if (v > 0) total += v;
310
+ let modelsArr = [];
311
+ if (total > 0) {
312
+ modelsArr = Array.from(models.entries())
313
+ .filter((e) => e[1] > 0)
314
+ .sort((a, b) => b[1] - a[1])
315
+ .slice(0, 6)
316
+ .map((e) => [setupSlugStrict(e[0]), Math.round((e[1] / total) * 1e4) / 1e4])
317
+ .filter((p) => p[0] && p[1] > 0);
318
+ }
319
+
320
+ return { v: SETUP_V, skills, mcp, hooks, tools: toolsArr, models: modelsArr };
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // SEASON READ — the single source of truth: scan every transcript under
325
+ // ~/.claude/projects/**/*.jsonl (subagent mirrors included), count only lines
326
+ // whose timestamp falls in the current season, with the SAME dedupe as the app.
327
+ // Full re-scan each call (no offsets) — simple and correct for a one-shot / poll
328
+ // CLI; fresh dedupe state per scan so watch mode never under- or double-counts.
329
+ // ---------------------------------------------------------------------------
330
+ function readSeason(now) {
331
+ now = now || Date.now();
332
+ const seasonId = currentSeasonId(now);
333
+ const seasonStart = SEASON_EPOCH + seasonId * SEASON_MS;
334
+ const dailyStart = dailyWindowStartMs(now);
335
+
336
+ const seenUsage = new Set();
337
+ const seenAgents = new Set();
338
+ const seenTools = new Set();
339
+ const toolTally = new Map();
340
+ const modelTally = new Map();
341
+ const skillTally = new Map();
342
+
343
+ let tokens = 0,
344
+ cost = 0,
345
+ residents = 0;
346
+ const dailyEntries = [];
347
+
348
+ const files = listJsonl(PROJECTS, []);
349
+ for (const f of files) {
350
+ let buf;
351
+ try {
352
+ buf = fs.readFileSync(f);
353
+ } catch (e) {
354
+ continue;
355
+ }
356
+ const nl = buf.lastIndexOf(0x0a);
357
+ if (nl < 0) continue; // no complete line
358
+ for (const line of buf.slice(0, nl).toString("utf8").split("\n")) {
359
+ if (!line) continue;
360
+ let o;
361
+ try {
362
+ o = JSON.parse(line);
363
+ } catch (e) {
364
+ continue;
365
+ }
366
+ if (!o.timestamp) continue; // no timestamp -> skip
367
+ const ts = Date.parse(o.timestamp);
368
+ if (!(ts >= seasonStart)) continue; // outside the current season
369
+ const u = o.message && o.message.usage;
370
+ if (u) {
371
+ const mid = o.message && o.message.id;
372
+ const rid = o.requestId;
373
+ let counted = true;
374
+ if (mid != null && rid != null) counted = remember(seenUsage, mid + ":" + rid, USAGE_CAP);
375
+ if (counted) {
376
+ const lineTk = tokensFromUsage(u);
377
+ tokens += lineTk;
378
+ cost += costFromUsage(u, o.message.model);
379
+ tallyForSetup(o, u, modelTally);
380
+ if (ts >= dailyStart && lineTk > 0) dailyEntries.push({ ts: ts, tokens: lineTk });
381
+ }
382
+ }
383
+ tallyTools(o, seenTools, toolTally, skillTally); // tools/skills — dedupe by block id
384
+ residents += countNewSubagents(o, seenAgents);
385
+ }
386
+ }
387
+
388
+ const daily = dailyBucketize(dailyEntries, now);
389
+ const setup = collectSetup({ toolTally, modelTally, skillTally });
390
+ const buildings = 2 + Math.floor(tokens / TOK_PER_BUILD_REAL);
391
+ return { seasonId, tokens, cost, residents, buildings, daily, setup, filesScanned: files.length, daysLeft: daysLeftIn(now) };
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // CITY BLOB — a deterministic, honest portrait built from the numbers alone
396
+ // (no game simulation, no localStorage). Same shape as the app sends:
397
+ // { v:1, seed, buildings, pop, types, marcos, era }
398
+ // - seed: FNV-1a hash of the username (same seed the site's fallback uses,
399
+ // so the skyline layout is stable and recognizable per user).
400
+ // - buildings: 2 + floor(tokens / 6000) — same as the report body.
401
+ // - pop: faithful sum of the game's per-building population model
402
+ // (game.js popForNormal), which drives how "lived-in" (lit) the
403
+ // skyline looks. Specials pop is omitted (the CLI places no
404
+ // specials), so types stays empty.
405
+ // - marcos: unlocked purely by token thresholds, exactly like the game
406
+ // (garden 100k, ferry 300k, lighthouse 1M, towers 3M).
407
+ // - era: floor(tokens / 2M).
408
+ // The server re-sanitizes everything; this only guarantees the shape.
409
+ // ---------------------------------------------------------------------------
410
+ const TOKEN_GARDEN = 100000;
411
+ const TOKEN_FERRY = 300000;
412
+ const TOKEN_LIGHTHOUSE = 1000000;
413
+ const TOKEN_TOWERS = 3000000;
414
+
415
+ function hashSeed(s) {
416
+ let h = 0x811c9dc5;
417
+ for (let i = 0; i < s.length; i++) {
418
+ h ^= s.charCodeAt(i);
419
+ h = Math.imul(h, 0x01000193);
420
+ }
421
+ return h >>> 0;
422
+ }
423
+ function seededRand(seed) {
424
+ return function () {
425
+ seed |= 0;
426
+ seed = (seed + 0x6d2b79f5) | 0;
427
+ let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);
428
+ x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
429
+ return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
430
+ };
431
+ }
432
+ // per-building population — verbatim from game.js (house 4-12, tall 14-42, tower 60-150).
433
+ function popForNormal(i, storeSeed) {
434
+ const r = seededRand((storeSeed ^ Math.imul(i + 1, 0x85ebca6b)) >>> 0);
435
+ const kind = i % 9 === 4 ? "tower" : r() < 0.42 ? "tall" : "low";
436
+ if (kind === "tower") return 60 + Math.floor(r() * 90);
437
+ if (kind === "tall") return 14 + Math.floor(r() * 28);
438
+ return 4 + Math.floor(r() * 8);
439
+ }
440
+
441
+ function buildCity(username, tokens) {
442
+ const t = Math.max(0, Number(tokens) || 0);
443
+ const seed = hashSeed(String(username || "anon"));
444
+ const buildings = 2 + Math.floor(t / TOK_PER_BUILD_REAL);
445
+ let pop = 0;
446
+ const cap = Math.min(buildings, 50000); // huge cities: sum up to 50k, then extrapolate
447
+ for (let i = 0; i < cap; i++) pop += popForNormal(i, seed);
448
+ if (buildings > cap && cap > 0) pop += Math.round((pop / cap) * (buildings - cap));
449
+ const marcos = [];
450
+ if (t >= TOKEN_GARDEN) marcos.push("garden");
451
+ if (t >= TOKEN_FERRY) marcos.push("ferry");
452
+ if (t >= TOKEN_LIGHTHOUSE) marcos.push("lighthouse");
453
+ if (t >= TOKEN_TOWERS) marcos.push("towers");
454
+ const era = Math.floor(t / ERA_STEP);
455
+ return { v: 1, seed: seed >>> 0, buildings: buildings, pop: pop, types: {}, marcos: marcos, era: era };
456
+ }
457
+
458
+ const MARCO_LABELS = {
459
+ garden: "waterfront garden",
460
+ ferry: "ferry across the water",
461
+ lighthouse: "lighthouse with a beam",
462
+ towers: "tower district",
463
+ festival: "lantern festival",
464
+ fireworks: "fireworks",
465
+ };
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // PAYLOAD SHAPING — mirrors client/placar.js. Only names & counts; the server
469
+ // re-validates all of it. undefined = don't attach that field.
470
+ // ---------------------------------------------------------------------------
471
+ const ACCENT_SLUGS = ["dourado", "teal", "rosa", "violeta", "verde", "ambar"];
472
+ const MARCO_RE = /^[a-z-]{1,24}$/;
473
+
474
+ function nonNeg(n) {
475
+ const v = Number(n);
476
+ return Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
477
+ }
478
+
479
+ function shapeCity(raw) {
480
+ try {
481
+ if (!raw || typeof raw !== "object") return undefined;
482
+ if (raw.v !== 1) return undefined;
483
+ const seed = Number(raw.seed);
484
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) return undefined;
485
+ const types = {};
486
+ if (raw.types && typeof raw.types === "object") {
487
+ const keys = Object.keys(raw.types);
488
+ for (let i = 0; i < keys.length && Object.keys(types).length < 24; i++) {
489
+ const k = String(keys[i]).slice(0, 24);
490
+ const v = nonNeg(raw.types[keys[i]]);
491
+ if (k && v > 0) types[k] = v;
492
+ }
493
+ }
494
+ const marcos = [];
495
+ if (Array.isArray(raw.marcos)) {
496
+ for (let j = 0; j < raw.marcos.length && marcos.length < 16; j++) {
497
+ const m = String(raw.marcos[j]).trim().toLowerCase();
498
+ if (MARCO_RE.test(m) && marcos.indexOf(m) < 0) marcos.push(m);
499
+ }
500
+ }
501
+ return {
502
+ v: 1,
503
+ seed: seed >>> 0,
504
+ buildings: nonNeg(raw.buildings),
505
+ pop: nonNeg(raw.pop),
506
+ types: types,
507
+ marcos: marcos,
508
+ era: nonNeg(raw.era),
509
+ };
510
+ } catch (e) {
511
+ return undefined;
512
+ }
513
+ }
514
+
515
+ const SETUP_MAX_BYTES = 3072;
516
+ function slugList(a, cap) {
517
+ if (!Array.isArray(a)) return [];
518
+ const seen = new Set(),
519
+ out = [];
520
+ for (const x of a) {
521
+ const s = setupSlugStrict(x);
522
+ if (s && !seen.has(s)) {
523
+ seen.add(s);
524
+ out.push(s);
525
+ if (out.length >= cap) break;
526
+ }
527
+ }
528
+ return out;
529
+ }
530
+ function shapeSetup(raw) {
531
+ try {
532
+ if (!raw || typeof raw !== "object" || raw.v !== 1) return undefined;
533
+ const tools = Array.isArray(raw.tools)
534
+ ? raw.tools
535
+ .filter((p) => Array.isArray(p) && p.length === 2)
536
+ .map((p) => [String(p[0]).replace(/[^A-Za-z0-9_.-]+/g, "").slice(0, 48), nonNeg(p[1])])
537
+ .filter((p) => p[0] && p[1] > 0)
538
+ .slice(0, 10)
539
+ : [];
540
+ const models = Array.isArray(raw.models)
541
+ ? raw.models
542
+ .filter((p) => Array.isArray(p) && p.length === 2)
543
+ .map((p) => [setupSlugStrict(p[0]), Math.max(0, Math.min(1, Number(p[1]) || 0))])
544
+ .filter((p) => p[0] && p[1] > 0)
545
+ .slice(0, 6)
546
+ : [];
547
+ const out = {
548
+ v: 1,
549
+ skills: slugList(raw.skills, 40),
550
+ mcp: slugList(raw.mcp, 20),
551
+ hooks: slugList(raw.hooks, 12),
552
+ tools: tools,
553
+ models: models,
554
+ };
555
+ if (Buffer.byteLength(JSON.stringify(out)) > SETUP_MAX_BYTES) return undefined;
556
+ return out;
557
+ } catch (e) {
558
+ return undefined;
559
+ }
560
+ }
561
+ function shapeDailyTokens(raw) {
562
+ try {
563
+ if (!raw || typeof raw !== "object") return undefined;
564
+ const keys = Object.keys(raw)
565
+ .filter((k) => /^\d{8}$/.test(k))
566
+ .sort()
567
+ .reverse();
568
+ const out = {};
569
+ let n = 0;
570
+ for (const k of keys) {
571
+ const v = nonNeg(raw[k]);
572
+ if (v > 0) {
573
+ out[k] = v;
574
+ if (++n >= 7) break;
575
+ }
576
+ }
577
+ return n ? out : undefined;
578
+ } catch (e) {
579
+ return undefined;
580
+ }
581
+ }
582
+ function shapeProfile(cfg) {
583
+ try {
584
+ const p = {};
585
+ if (typeof cfg.cityName === "string") {
586
+ const c = cfg.cityName.trim();
587
+ p.cityName = c ? c.slice(0, 24) : "";
588
+ }
589
+ if (typeof cfg.motto === "string") {
590
+ const m = cfg.motto.trim();
591
+ p.motto = m ? m.slice(0, 48) : "";
592
+ }
593
+ if (typeof cfg.accent === "string") {
594
+ const a = cfg.accent.trim().toLowerCase();
595
+ if (ACCENT_SLUGS.indexOf(a) >= 0) p.accent = a;
596
+ }
597
+ return Object.keys(p).length ? p : undefined;
598
+ } catch (e) {
599
+ return undefined;
600
+ }
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // CONFIG — ~/.tokentown-placar.json (or $TOKENTOWN_CONFIG). Same shape as the
605
+ // app's reporter config.
606
+ // ---------------------------------------------------------------------------
607
+ const DEFAULT_CONFIG = {
608
+ enabled: false,
609
+ username: "",
610
+ key: "",
611
+ url: "",
612
+ shareSetup: false,
613
+ cityName: "",
614
+ motto: "",
615
+ accent: "",
616
+ };
617
+
618
+ function readConfigRaw(p) {
619
+ try {
620
+ return JSON.parse(fs.readFileSync(p, "utf8"));
621
+ } catch (e) {
622
+ return null;
623
+ }
624
+ }
625
+ function readConfig(p) {
626
+ const parsed = readConfigRaw(p);
627
+ return Object.assign({}, DEFAULT_CONFIG, parsed || {});
628
+ }
629
+ function writeConfig(p, cfg) {
630
+ try {
631
+ fs.mkdirSync(path.dirname(p), { recursive: true });
632
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
633
+ return true;
634
+ } catch (e) {
635
+ return false;
636
+ }
637
+ }
638
+ function newKey() {
639
+ return crypto.randomBytes(24).toString("hex"); // 48 hex chars
640
+ }
641
+
642
+ const USERNAME_RE = /^[a-z0-9-]{2,24}$/;
643
+
644
+ function cityUrlFor(cfg) {
645
+ const u = String(cfg.url || DEFAULT_URL);
646
+ let origin = u.replace(/\/api\/report\/?$/, "");
647
+ if (!origin || !/^https?:\/\//.test(origin)) origin = SITE_ORIGIN;
648
+ return origin.replace(/\/+$/, "") + "/u/" + String(cfg.username).trim().toLowerCase();
649
+ }
650
+
651
+ // ---------------------------------------------------------------------------
652
+ // TERMINAL PRETTY-PRINT
653
+ // ---------------------------------------------------------------------------
654
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
655
+ const c = (code, s) => (useColor ? `[${code}m${s}` : s);
656
+ const bold = (s) => c("1", s);
657
+ const dim = (s) => c("2", s);
658
+ const gold = (s) => c("33", s);
659
+ const cyan = (s) => c("36", s);
660
+ const green = (s) => c("32", s);
661
+ const red = (s) => c("31", s);
662
+
663
+ function fmtInt(n) {
664
+ return Number(n || 0).toLocaleString("en-US");
665
+ }
666
+ function fmtCompact(n) {
667
+ n = Number(n || 0);
668
+ if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M";
669
+ if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
670
+ return String(Math.round(n));
671
+ }
672
+ function line(s) {
673
+ process.stdout.write((s == null ? "" : s) + "\n");
674
+ }
675
+
676
+ function banner() {
677
+ line("");
678
+ line(" " + gold("▛▀▖") + " " + bold("TOKENTOWN") + " " + dim("where prompts become skyline"));
679
+ line("");
680
+ }
681
+
682
+ function printSummary(cfg, data, city, opts) {
683
+ opts = opts || {};
684
+ const url = cityUrlFor(cfg);
685
+ line(" " + bold("Your city: ") + cyan(url));
686
+ line("");
687
+ line(" " + gold("●") + " season " + bold("T" + data.seasonId) + dim(" · " + data.daysLeft + " day" + (data.daysLeft === 1 ? "" : "s") + " left"));
688
+ line(" " + bold(fmtInt(data.tokens)) + " tokens " + dim("(" + fmtCompact(data.tokens) + ")") + " → " + bold(fmtInt(city.buildings)) + " buildings");
689
+ line(" " + dim("population ") + fmtInt(city.pop) + dim(" · residents (subagents) ") + fmtInt(data.residents) + dim(" · est. cost ") + "$" + Number(data.cost || 0).toFixed(2));
690
+ const landmarks = (city.marcos || []).map((m) => MARCO_LABELS[m] || m);
691
+ if (landmarks.length) line(" " + dim("landmarks: ") + landmarks.join(dim(" · ")));
692
+ else line(" " + dim("landmarks: none yet — first one lights up at 100k tokens"));
693
+ if (cfg.shareSetup) {
694
+ const s = data.setup || {};
695
+ const bits = [];
696
+ if (s.skills && s.skills.length) bits.push(s.skills.length + " skills");
697
+ if (s.mcp && s.mcp.length) bits.push(s.mcp.length + " MCP");
698
+ if (s.tools && s.tools.length) bits.push(s.tools.length + " tools");
699
+ if (s.models && s.models.length) bits.push(s.models.length + " models");
700
+ if (bits.length) line(" " + dim("setup shared: ") + bits.join(dim(", ")));
701
+ }
702
+ line("");
703
+ }
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // PAYLOAD + REPORT
707
+ // ---------------------------------------------------------------------------
708
+ // NOTE: we deliberately DO NOT send `city`. The CLI can't run the game
709
+ // simulation, so it can't reproduce the app's special buildings — and the
710
+ // leaderboard does last-writer-wins on the city field, so a CLI report would
711
+ // clobber the rich city of anyone who also runs the desktop app. The server
712
+ // already does the right thing without it: it PRESERVES an existing city when a
713
+ // report arrives with no `city`, and for CLI-only users it renders a full
714
+ // skyline seeded from the username (with the same token-threshold landmarks).
715
+ // The `buildCity()` blob is still computed locally to drive the terminal
716
+ // summary (buildings / population / landmarks); it just never leaves the machine.
717
+ function buildPayload(cfg, data) {
718
+ const username = String(cfg.username).trim().toLowerCase();
719
+ const payload = {
720
+ username: username,
721
+ key: cfg.key,
722
+ seasonId: nonNeg(data.seasonId),
723
+ tokens: nonNeg(data.tokens),
724
+ cost: Number(data.cost) >= 0 ? Number(data.cost) : 0,
725
+ residents: nonNeg(data.residents),
726
+ buildings: nonNeg(data.buildings),
727
+ };
728
+ const profile = shapeProfile(cfg);
729
+ if (profile) payload.profile = profile;
730
+ const daily = shapeDailyTokens(data.daily);
731
+ if (daily) payload.dailyTokens = daily;
732
+ if (cfg.shareSetup) {
733
+ const setup = shapeSetup(data.setup);
734
+ if (setup) payload.setup = setup;
735
+ }
736
+ return payload;
737
+ }
738
+
739
+ // redacted copy of the payload for printing (never echo the key to the terminal).
740
+ function redactedPayload(payload) {
741
+ const p = Object.assign({}, payload);
742
+ if (p.key) p.key = "<hidden " + String(p.key).length + " chars>";
743
+ return p;
744
+ }
745
+
746
+ async function postReport(cfg, payload) {
747
+ if (typeof fetch !== "function") {
748
+ return { ok: false, status: 0, error: "global fetch missing — need Node 18+" };
749
+ }
750
+ try {
751
+ const res = await fetch(cfg.url, {
752
+ method: "POST",
753
+ headers: { "content-type": "application/json" },
754
+ body: JSON.stringify(payload),
755
+ });
756
+ let json = null;
757
+ try {
758
+ json = await res.json();
759
+ } catch (e) {}
760
+ return { ok: res.ok, status: res.status, json: json };
761
+ } catch (e) {
762
+ return { ok: false, status: 0, error: (e && e.message) || String(e) };
763
+ }
764
+ }
765
+
766
+ // ---------------------------------------------------------------------------
767
+ // ONBOARDING (first run) — ask username, generate a key, ask about setup, save.
768
+ // ---------------------------------------------------------------------------
769
+ // Line reader with a queue: captures every stdin line whether it arrives before
770
+ // or after we ask for it, so piped/CI input never races with the prompts (the
771
+ // classic rl.question drop-on-fast-EOF). next() resolves to null on EOF.
772
+ function makeLineReader() {
773
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
774
+ const queue = [];
775
+ const waiters = [];
776
+ let closed = false;
777
+ rl.on("line", (l) => {
778
+ if (waiters.length) waiters.shift()(l);
779
+ else queue.push(l);
780
+ });
781
+ rl.on("close", () => {
782
+ closed = true;
783
+ while (waiters.length) waiters.shift()(null);
784
+ });
785
+ return {
786
+ next() {
787
+ if (queue.length) return Promise.resolve(queue.shift());
788
+ if (closed) return Promise.resolve(null);
789
+ return new Promise((res) => waiters.push(res));
790
+ },
791
+ close() {
792
+ try {
793
+ rl.close();
794
+ } catch (e) {}
795
+ },
796
+ };
797
+ }
798
+
799
+ async function onboard(p) {
800
+ line("");
801
+ line(" " + bold("Welcome to TOKENTOWN.") + " Let's put your city on the map.");
802
+ line(" " + dim("Only your username and the season numbers are ever sent —"));
803
+ line(" " + dim("never prompts, code, conversation content, or project names."));
804
+ line("");
805
+ const lr = makeLineReader();
806
+ const ask = async (q) => {
807
+ process.stdout.write(q);
808
+ const l = await lr.next();
809
+ return l == null ? null : l;
810
+ };
811
+ let username = "";
812
+ for (;;) {
813
+ const l = await ask(" " + bold("Pick a username") + dim(" (a-z, 0-9, -, 2–24 chars): "));
814
+ if (l == null) {
815
+ // stdin closed with no valid username -> can't onboard.
816
+ lr.close();
817
+ line("");
818
+ line(" " + red("No username given — run `npx tokentown` again to join.") );
819
+ line("");
820
+ throw new Error("onboarding aborted: no username on stdin");
821
+ }
822
+ const raw = l.trim().toLowerCase();
823
+ if (USERNAME_RE.test(raw)) {
824
+ username = raw;
825
+ break;
826
+ }
827
+ line("");
828
+ line(" " + red(" → use only a-z, 0-9 and -, between 2 and 24 characters."));
829
+ }
830
+ const shareLine = await ask(
831
+ " " + bold("Share your setup?") + dim(" skills/MCP/tools/models, names & counts only [y/N]: ")
832
+ );
833
+ const shareRaw = (shareLine == null ? "" : shareLine).trim().toLowerCase();
834
+ const shareSetup = shareRaw === "y" || shareRaw === "yes" || shareRaw === "s" || shareRaw === "sim";
835
+ lr.close();
836
+ line("");
837
+
838
+ const cfg = Object.assign({}, DEFAULT_CONFIG, {
839
+ enabled: true,
840
+ username: username,
841
+ key: newKey(),
842
+ url: DEFAULT_URL,
843
+ shareSetup: shareSetup,
844
+ });
845
+ const saved = writeConfig(p, cfg);
846
+ line("");
847
+ line(" " + green("✓") + " saved config to " + dim(p) + " " + dim("(key generated locally)"));
848
+ if (!saved) line(" " + red("! couldn't write the config file — will report this once but won't remember you next time."));
849
+ line("");
850
+ return cfg;
851
+ }
852
+
853
+ // returns { cfg, fresh }. Never rewrites an existing config except to backfill a
854
+ // missing key.
855
+ async function loadOrOnboard(p) {
856
+ const raw = readConfigRaw(p);
857
+ if (raw && typeof raw === "object" && raw.username) {
858
+ const cfg = Object.assign({}, DEFAULT_CONFIG, raw);
859
+ if (!cfg.url) cfg.url = DEFAULT_URL;
860
+ if (!cfg.key) {
861
+ cfg.key = newKey();
862
+ // persist the freshly generated key (merge onto the on-disk object).
863
+ writeConfig(p, Object.assign({}, raw, { key: cfg.key, url: cfg.url }));
864
+ }
865
+ return { cfg: cfg, fresh: false };
866
+ }
867
+ const cfg = await onboard(p);
868
+ return { cfg: cfg, fresh: true };
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // COMMANDS
873
+ // ---------------------------------------------------------------------------
874
+ function reportResultLine(cfg, r) {
875
+ if (r.ok && r.status === 200) {
876
+ const updated = r.json && r.json.updated;
877
+ line(" " + green("✓ reported") + dim(" (HTTP 200" + (updated === false ? ", no change — already up to date" : "") + ")"));
878
+ return true;
879
+ }
880
+ if (r.status === 429) {
881
+ line(" " + gold("• easy there") + dim(" — the board takes one report per minute. Your numbers are safe; try again shortly."));
882
+ return true; // not a hard failure
883
+ }
884
+ if (r.status === 403) {
885
+ line(" " + red("✗ that username is taken by a different key.") + dim(" Pick another username in " + configPath() + "."));
886
+ return false;
887
+ }
888
+ if (r.status === 0) {
889
+ line(" " + red("✗ couldn't reach the leaderboard") + dim(" (" + (r.error || "network error") + ")."));
890
+ line(" " + dim(" Your usage was read fine — nothing was lost. Try again later."));
891
+ return false;
892
+ }
893
+ const msg = (r.json && r.json.error) || ("HTTP " + r.status);
894
+ line(" " + red("✗ report rejected: ") + dim(msg));
895
+ return false;
896
+ }
897
+
898
+ async function cmdReport(cfg, opts) {
899
+ opts = opts || {};
900
+ const data = readSeason();
901
+ const username = String(cfg.username).trim().toLowerCase();
902
+ const city = buildCity(username, data.tokens); // local-only: drives the terminal summary; NOT sent
903
+ const payload = buildPayload(cfg, data);
904
+
905
+ if (opts.dryRun) {
906
+ line(" " + bold("DRY RUN") + dim(" — nothing will be sent. This is exactly what a real report would POST:"));
907
+ line(" " + dim("→ " + cfg.url));
908
+ line("");
909
+ line(
910
+ JSON.stringify(redactedPayload(payload), null, 2)
911
+ .split("\n")
912
+ .map((l) => " " + l)
913
+ .join("\n")
914
+ );
915
+ line("");
916
+ printSummary(cfg, data, city, opts);
917
+ line(" " + dim("(dry run — no request sent, leaderboard untouched)"));
918
+ line("");
919
+ return true;
920
+ }
921
+
922
+ const r = await postReport(cfg, payload);
923
+ const ok = reportResultLine(cfg, r);
924
+ if (ok) printSummary(cfg, data, city, opts);
925
+ return ok;
926
+ }
927
+
928
+ async function cmdWatch(cfg) {
929
+ const EVERY_MS = 10 * 60 * 1000; // ~10 min
930
+ line(" " + bold("watching") + dim(" — reporting every ~10 min. Ctrl+C to stop.") + "\n");
931
+ process.on("SIGINT", () => {
932
+ line("\n " + dim("stopped watching. your city is saved at ") + cyan(cityUrlFor(cfg)) + "\n");
933
+ process.exit(0);
934
+ });
935
+
936
+ async function tick() {
937
+ const data = readSeason();
938
+ const username = String(cfg.username).trim().toLowerCase();
939
+ const city = buildCity(username, data.tokens); // local-only: drives the summary line; NOT sent
940
+ const payload = buildPayload(cfg, data);
941
+ const r = await postReport(cfg, payload);
942
+ const stamp = new Date().toLocaleTimeString("en-US", { hour12: false });
943
+ if (r.ok && r.status === 200) {
944
+ const changed = r.json && r.json.updated;
945
+ line(
946
+ " " +
947
+ dim(stamp) +
948
+ " " +
949
+ green("✓") +
950
+ " " +
951
+ fmtInt(data.tokens) +
952
+ " tokens" +
953
+ dim(" · " + fmtInt(city.buildings) + " buildings · $" + Number(data.cost || 0).toFixed(2)) +
954
+ (changed === false ? dim(" (no change)") : "")
955
+ );
956
+ } else if (r.status === 429) {
957
+ line(" " + dim(stamp) + " " + gold("•") + dim(" throttled (1/min) — will catch up next cycle"));
958
+ } else if (r.status === 0) {
959
+ line(" " + dim(stamp) + " " + red("✗") + dim(" network error (" + (r.error || "offline") + ") — will retry next cycle"));
960
+ } else {
961
+ line(" " + dim(stamp) + " " + red("✗") + dim(" " + ((r.json && r.json.error) || "HTTP " + r.status)));
962
+ }
963
+ const t = setTimeout(tick, EVERY_MS);
964
+ if (t && t.unref) t.unref();
965
+ }
966
+ await tick();
967
+ // keep the process alive between ticks
968
+ setInterval(() => {}, 1 << 30);
969
+ }
970
+
971
+ function printHelp() {
972
+ banner();
973
+ line(" " + bold("Usage"));
974
+ line(" npx tokentown report your season once, print your city URL");
975
+ line(" npx tokentown watch keep running, report every ~10 minutes");
976
+ line(" npx tokentown --dry-run read & print what WOULD be sent (no request)");
977
+ line(" npx tokentown --help this help");
978
+ line("");
979
+ line(" " + bold("What it does"));
980
+ line(" Reads your real Claude Code usage under ~/.claude/projects and reports");
981
+ line(" this season's numbers to " + cyan(SITE_ORIGIN) + ".");
982
+ line("");
983
+ line(" " + bold("Privacy"));
984
+ line(" Only your username and the numbers are sent — never prompts, code,");
985
+ line(" conversation content, or project names. Sharing your setup is opt-in.");
986
+ line("");
987
+ line(" " + dim("Config: " + configPath()));
988
+ line("");
989
+ }
990
+
991
+ async function main() {
992
+ const argv = process.argv.slice(2);
993
+ const wantsHelp = argv.includes("--help") || argv.includes("-h") || argv.includes("help");
994
+ const dryRun = argv.includes("--dry-run") || argv.includes("--dry") || argv.includes("-n");
995
+ const watch = argv.includes("watch") || argv.includes("--watch");
996
+
997
+ if (wantsHelp) {
998
+ printHelp();
999
+ return 0;
1000
+ }
1001
+
1002
+ banner();
1003
+
1004
+ const p = configPath();
1005
+ const { cfg, fresh } = await loadOrOnboard(p);
1006
+
1007
+ if (!fresh) {
1008
+ line(" " + dim("reporting as ") + bold(String(cfg.username).trim().toLowerCase()) + dim(" · config ") + dim(p));
1009
+ line("");
1010
+ }
1011
+
1012
+ if (watch) {
1013
+ if (dryRun) {
1014
+ // dry watch: just print one read and stop (no loop needed to prove reads).
1015
+ await cmdReport(cfg, { dryRun: true });
1016
+ return 0;
1017
+ }
1018
+ await cmdWatch(cfg);
1019
+ return 0; // (never really returns — watch keeps the loop alive)
1020
+ }
1021
+
1022
+ const ok = await cmdReport(cfg, { dryRun: dryRun });
1023
+ return ok ? 0 : 1;
1024
+ }
1025
+
1026
+ // Run as a CLI only when executed directly; when required as a module (tests,
1027
+ // tooling) just export the pure pieces below. never crash with a raw stack
1028
+ // trace — always a friendly message.
1029
+ if (require.main === module) {
1030
+ // Exit cleanly if stdout is closed early (e.g. `npx tokentown | head`, or the
1031
+ // reader quits a pager) instead of crashing with an EPIPE stack trace.
1032
+ process.stdout.on("error", (e) => {
1033
+ if (e && e.code === "EPIPE") process.exit(0);
1034
+ });
1035
+ process.stderr.on("error", () => {});
1036
+ main()
1037
+ .then((code) => {
1038
+ process.exitCode = typeof code === "number" ? code : 0;
1039
+ })
1040
+ .catch((e) => {
1041
+ line("");
1042
+ line(" " + red("Something went wrong: ") + dim((e && e.message) || String(e)));
1043
+ line(" " + dim("This is a bug — please report it at https://github.com/AElise08/tokentown/issues"));
1044
+ process.exitCode = 1;
1045
+ });
1046
+ }
1047
+
1048
+ module.exports = {
1049
+ // reading
1050
+ readSeason,
1051
+ currentSeasonId,
1052
+ daysLeftIn,
1053
+ priceFor,
1054
+ tokensFromUsage,
1055
+ costFromUsage,
1056
+ collectSetup,
1057
+ dailyBucketize,
1058
+ // city
1059
+ buildCity,
1060
+ hashSeed,
1061
+ // payload shaping
1062
+ buildPayload,
1063
+ shapeCity,
1064
+ shapeSetup,
1065
+ shapeDailyTokens,
1066
+ shapeProfile,
1067
+ // config
1068
+ readConfig,
1069
+ writeConfig,
1070
+ newKey,
1071
+ cityUrlFor,
1072
+ configPath,
1073
+ DEFAULT_URL,
1074
+ SITE_ORIGIN,
1075
+ };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "tokentown",
3
+ "version": "0.1.0",
4
+ "description": "Report your real Claude Code token usage to the TOKENTOWN leaderboard — a pixel city that grows from your tokens. No app to install.",
5
+ "bin": {
6
+ "tokentown": "cli.js"
7
+ },
8
+ "type": "commonjs",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/AElise08/tokentown.git",
16
+ "directory": "cli"
17
+ },
18
+ "homepage": "https://tokentown-gamma.vercel.app",
19
+ "bugs": {
20
+ "url": "https://github.com/AElise08/tokentown/issues"
21
+ },
22
+ "keywords": [
23
+ "claude-code",
24
+ "claude",
25
+ "tokens",
26
+ "usage",
27
+ "leaderboard",
28
+ "cli",
29
+ "tokentown"
30
+ ],
31
+ "files": [
32
+ "cli.js",
33
+ "README.md"
34
+ ]
35
+ }