@vimoxshah/tokenflow 1.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 (101) hide show
  1. package/CONTRIBUTING.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/Refresh & Open Dashboard.command +22 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/tokenflow.js +1342 -0
  7. package/docs/architecture.md +193 -0
  8. package/docs/cli.md +390 -0
  9. package/docs/configuration.md +281 -0
  10. package/docs/creating-provider.md +262 -0
  11. package/docs/data-model.md +213 -0
  12. package/docs/getting-started.md +266 -0
  13. package/docs/live-mode.md +199 -0
  14. package/docs/media/architecture-hero.svg +86 -0
  15. package/docs/media/cost-editorial-dark.png +0 -0
  16. package/docs/media/health-terminal-light.png +0 -0
  17. package/docs/media/menubar-dark.png +0 -0
  18. package/docs/media/menubar-light.png +0 -0
  19. package/docs/media/models-terminal-dark.png +0 -0
  20. package/docs/media/overview-aurora-dark.png +0 -0
  21. package/docs/media/time-aurora-light.png +0 -0
  22. package/docs/providers.md +309 -0
  23. package/docs/skill.md +64 -0
  24. package/docs/troubleshooting.md +207 -0
  25. package/examples/config.example.yaml +92 -0
  26. package/examples/demo-data/README.md +38 -0
  27. package/examples/demo-data/sample-usage.csv +11 -0
  28. package/package.json +74 -0
  29. package/scripts/build-dmg.sh +33 -0
  30. package/scripts/build-menubar-app.sh +67 -0
  31. package/scripts/lint.js +111 -0
  32. package/scripts/validate-install.js +140 -0
  33. package/skills/tokenflow/SKILL.md +392 -0
  34. package/skills/tokenflow/examples/config.yaml +92 -0
  35. package/skills/tokenflow/examples/generic-mapping.json +26 -0
  36. package/skills/tokenflow/examples/session-transcript.md +191 -0
  37. package/skills/tokenflow/providers/adapter-template.js +135 -0
  38. package/skills/tokenflow/providers/detection-matrix.md +142 -0
  39. package/skills/tokenflow/schemas/config.schema.json +107 -0
  40. package/skills/tokenflow/schemas/normalized-record.json +63 -0
  41. package/src/analytics/aggregate.js +247 -0
  42. package/src/analytics/anomalies.js +222 -0
  43. package/src/analytics/capacity.js +278 -0
  44. package/src/analytics/comparison.js +96 -0
  45. package/src/analytics/dimensions.js +230 -0
  46. package/src/analytics/efficiency.js +138 -0
  47. package/src/analytics/forecast.js +202 -0
  48. package/src/analytics/index.js +327 -0
  49. package/src/analytics/insights.js +283 -0
  50. package/src/analytics/milestones.js +91 -0
  51. package/src/analytics/peak.js +106 -0
  52. package/src/analytics/productivity.js +166 -0
  53. package/src/analytics/token-usage.js +267 -0
  54. package/src/commands/diagnostics.js +88 -0
  55. package/src/commands/digest.js +155 -0
  56. package/src/commands/models-compare.js +96 -0
  57. package/src/core/budget.js +142 -0
  58. package/src/core/bundle.js +191 -0
  59. package/src/core/config.js +202 -0
  60. package/src/core/delivery.js +109 -0
  61. package/src/core/geo.js +99 -0
  62. package/src/core/ingest.js +457 -0
  63. package/src/core/interface-map.js +55 -0
  64. package/src/core/jsonl.js +124 -0
  65. package/src/core/live-status.js +417 -0
  66. package/src/core/model-map.js +157 -0
  67. package/src/core/notify.js +83 -0
  68. package/src/core/pricing.js +288 -0
  69. package/src/core/prompt-analytics.js +127 -0
  70. package/src/core/registry.js +107 -0
  71. package/src/core/restore.js +261 -0
  72. package/src/core/schedule.js +120 -0
  73. package/src/core/schema.js +316 -0
  74. package/src/core/sqlite.js +96 -0
  75. package/src/core/store.js +493 -0
  76. package/src/core/sync.js +151 -0
  77. package/src/core/units.js +147 -0
  78. package/src/core/validate.js +123 -0
  79. package/src/core/watch.js +287 -0
  80. package/src/core/yaml.js +209 -0
  81. package/src/export/bundler.js +107 -0
  82. package/src/export/csv.js +100 -0
  83. package/src/export/html-snapshot.js +101 -0
  84. package/src/export/menubar.js +158 -0
  85. package/src/index.js +18 -0
  86. package/src/providers/anthropic/index.js +294 -0
  87. package/src/providers/cline/index.js +120 -0
  88. package/src/providers/cursor/index.js +143 -0
  89. package/src/providers/generic/index.js +268 -0
  90. package/src/providers/git/index.js +188 -0
  91. package/src/providers/headroom/index.js +114 -0
  92. package/src/providers/hermes/index.js +299 -0
  93. package/src/providers/mock/index.js +117 -0
  94. package/src/providers/openai/index.js +370 -0
  95. package/src/providers/opencode/index.js +245 -0
  96. package/src/sdk.js +46 -0
  97. package/src/server/server.js +264 -0
  98. package/src/ui/app.js +2473 -0
  99. package/src/ui/charts.js +925 -0
  100. package/src/ui/index.html +42 -0
  101. package/src/ui/styles.css +644 -0
@@ -0,0 +1,1342 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tokenflow — the CLI.
4
+ *
5
+ * Every command is safe to run repeatedly and never writes outside
6
+ * $TOKENFLOW_HOME (default ~/.tokenflow). Nothing here makes a network
7
+ * request.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { loadProviders, listProviders, getProvider } from '../src/core/registry.js';
15
+ import { loadConfig, saveConfig, paths, ensureDirs, DEFAULT_CONFIG, merge } from '../src/core/config.js';
16
+ import { refresh } from '../src/core/ingest.js';
17
+ import { buildBundle, queryRecords } from '../src/core/bundle.js';
18
+ import { Store, readJson, writeJson } from '../src/core/store.js';
19
+ import { computeView } from '../src/analytics/index.js';
20
+ import { compact, int, usd, pct, signedPct, longDate, shortDate, relativeTime, humanDuration } from '../src/core/units.js';
21
+ import { streamRecordsCsv, exportFilename } from '../src/export/csv.js';
22
+ import { buildSnapshot } from '../src/export/html-snapshot.js';
23
+ import { startServer } from '../src/server/server.js';
24
+ import { validateUsage } from '../src/core/validate.js';
25
+ import { MAPPABLE_FIELDS, parseDelimited } from '../src/providers/generic/index.js';
26
+ import { stringifyYaml, parseYaml } from '../src/core/yaml.js';
27
+ import { PRICING_TABLE_VERSION, PRICING_SOURCES, TIER_MULTIPLIERS, buildPriceBook } from '../src/core/pricing.js';
28
+ import {
29
+ buildLiveStatus, currentStatus, readLiveStatus, withComputedFreshness, barLine,
30
+ } from '../src/core/live-status.js';
31
+ import {
32
+ startWatch, runCycle, stopWatch, watchIsRunning, releaseWatchLock,
33
+ } from '../src/core/watch.js';
34
+ import { renderXbar, installSwiftBarPlugin } from '../src/export/menubar.js';
35
+
36
+ const C = process.stdout.isTTY && !process.env.NO_COLOR
37
+ ? { r: '\x1b[0m', b: '\x1b[1m', dim: '\x1b[2m', g: '\x1b[32m', y: '\x1b[33m', red: '\x1b[31m', c: '\x1b[36m', mag: '\x1b[35m' }
38
+ : { r: '', b: '', dim: '', g: '', y: '', red: '', c: '', mag: '' };
39
+
40
+ const argv = process.argv.slice(2);
41
+ const cmd = (argv[0] || '').replace(/^-+/, '') || 'status';
42
+ const flags = parseFlags(argv.slice(1));
43
+
44
+ main().catch((err) => {
45
+ console.error(`${C.red}✗ ${err.message}${C.r}`);
46
+ if (err.hint) console.error(` ${C.dim}${err.hint}${C.r}`);
47
+ if (flags.debug) console.error(err.stack);
48
+ process.exit(1);
49
+ });
50
+
51
+ async function main() {
52
+ if (['help', 'h', '?'].includes(cmd) || flags.help) return help();
53
+ if (cmd === 'version' || flags.version) {
54
+ const pkg = readJson(path.join(root(), 'package.json'), {});
55
+ return console.log(pkg.version || '0.0.0');
56
+ }
57
+ await loadProviders();
58
+ switch (cmd) {
59
+ case 'setup': return cmdSetup();
60
+ case 'providers': return cmdProviders();
61
+ case 'provider': return cmdProvider();
62
+ case 'refresh': return cmdRefresh();
63
+ case 'status': return cmdStatus();
64
+ case 'dashboard': case 'serve': case 'ui': return cmdDashboard();
65
+ case 'up': case 'open': return cmdUp();
66
+ case 'export': return cmdExport();
67
+ case 'pricing': return cmdPricing();
68
+ case 'import': return cmdImport();
69
+ case 'restore': return cmdRestore();
70
+ case 'config': return cmdConfig();
71
+ case 'demo': return cmdDemo();
72
+ case 'validate': return cmdValidate();
73
+ case 'doctor': return cmdDoctor();
74
+ case 'compact': return cmdCompact();
75
+ case 'reset': return cmdReset();
76
+ case 'watch': return cmdWatch();
77
+ case 'usage': return cmdUsage();
78
+ case 'cost': return cmdCost();
79
+ case 'capacity': return cmdCapacity();
80
+ case 'forecast': return cmdForecast();
81
+ case 'menubar': return cmdMenubar();
82
+ case 'digest': return cmdDigest();
83
+ case 'schedule': return cmdSchedule();
84
+ case 'budget': return cmdBudget();
85
+ case 'sync': return cmdSync();
86
+ case 'models-compare': return cmdModelsCompare();
87
+ case 'diagnostics': return cmdDiagnostics();
88
+ default:
89
+ console.error(`${C.red}Unknown command "${cmd}".${C.r}\n`);
90
+ return help(1);
91
+ }
92
+ }
93
+
94
+ // ==================================================================== setup ==
95
+
96
+ async function cmdSetup() {
97
+ const p = ensureDirs();
98
+ const cfg = loadConfig();
99
+ const ctx = { config: cfg, home: os.homedir() };
100
+ console.log(`\n${C.b}Tokenflow — setup${C.r}`);
101
+ console.log(`${C.dim}config home: ${p.root}${C.r}\n`);
102
+
103
+ const detected = [];
104
+ for (const pr of listProviders()) {
105
+ let det;
106
+ try { det = await pr.detect(ctx); } catch (e) { det = { available: false, detail: e.message }; }
107
+ const mark = det.available ? `${C.g}✓${C.r}` : `${C.dim}○${C.r}`;
108
+ console.log(` ${mark} ${pad(pr.name, 42)} ${C.dim}${det.detail || ''}${C.r}`);
109
+ if (det.available && pr.id !== 'mock') detected.push(pr.id);
110
+ }
111
+
112
+ if (!detected.length) {
113
+ console.log(`\n${C.y}No usage sources found on this machine.${C.r}`);
114
+ console.log(' Options:');
115
+ console.log(' · tokenflow demo explore with synthetic data');
116
+ console.log(' · tokenflow import <file> import a CSV/JSON/JSONL/SQLite export');
117
+ console.log(' · docs/providers.md what each adapter looks for\n');
118
+ }
119
+
120
+ cfg.providers = detected;
121
+ cfg.timezone = cfg.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
122
+ cfg.identity = { user: cfg.identity?.user || os.userInfo().username, machine: cfg.identity?.machine || os.hostname(), team: cfg.identity?.team || null };
123
+ for (const id of detected) if (!cfg.sources[id]) cfg.sources[id] = { type: 'auto' };
124
+ const file = saveConfig(cfg);
125
+
126
+ console.log(`\n${C.g}✓${C.r} wrote ${file}`);
127
+ console.log(` enabled providers: ${detected.length ? detected.join(', ') : '(none)'}`);
128
+ console.log(` timezone: ${cfg.timezone}\n`);
129
+ console.log(`Next: ${C.c}tokenflow refresh${C.r} then ${C.c}tokenflow dashboard${C.r}\n`);
130
+ }
131
+
132
+ // ================================================================ providers ==
133
+
134
+ async function cmdProviders() {
135
+ const cfg = loadConfig();
136
+ const ctx = { config: cfg, home: os.homedir() };
137
+ const rows = [];
138
+ for (const pr of listProviders()) {
139
+ let det;
140
+ try { det = await pr.detect(ctx); } catch (e) { det = { available: false, detail: e.message }; }
141
+ const enabled = !cfg.providers.length || cfg.providers.includes(pr.id);
142
+ rows.push({ id: pr.id, name: pr.name, ...pr.getMetadata(), ...det, enabled });
143
+ }
144
+ if (flags.json) return console.log(JSON.stringify(rows, null, 2));
145
+ console.log('');
146
+ for (const r of rows) {
147
+ const status = !r.available ? `${C.dim}Not detected${C.r}` : r.enabled ? `${C.g}Connected${C.r}` : `${C.y}Detected (disabled)${C.r}`;
148
+ const mark = r.available ? (r.enabled ? `${C.g}✓${C.r}` : `${C.y}!${C.r}`) : `${C.dim}○${C.r}`;
149
+ console.log(` ${mark} ${pad(r.name, 40)} ${pad(stripAnsi(status), 22)} ${C.dim}${r.detail || ''}${C.r}`.replace(stripAnsi(status), status));
150
+ if (r.measurement !== 'primary') console.log(` ${C.dim}measurement: ${r.measurement} — ${r.measurement === 'overlay' ? 'excluded from token totals by default' : 'no token counts; activity only'}${C.r}`);
151
+ }
152
+ console.log(`\n ${C.dim}enable/disable: tokenflow provider add <id> | tokenflow provider remove <id>${C.r}\n`);
153
+ }
154
+
155
+ async function cmdProvider() {
156
+ const action = argv[1];
157
+ const id = argv[2];
158
+ const cfg = loadConfig();
159
+ if (!['add', 'remove', 'rm', 'list'].includes(action)) {
160
+ throw new Error('usage: tokenflow provider <add|remove|list> [id]');
161
+ }
162
+ if (action === 'list') return cmdProviders();
163
+ if (!id) throw new Error(`usage: tokenflow provider ${action} <id>`);
164
+ if (action === 'add') {
165
+ if (!getProvider(id)) {
166
+ throw Object.assign(new Error(`no provider "${id}"`), { hint: `available: ${listProviders().map((p) => p.id).join(', ')}` });
167
+ }
168
+ if (!cfg.providers.includes(id)) cfg.providers.push(id);
169
+ if (!cfg.sources[id]) cfg.sources[id] = { type: 'auto' };
170
+ } else {
171
+ cfg.providers = cfg.providers.filter((x) => x !== id);
172
+ }
173
+ saveConfig(cfg);
174
+ console.log(`${C.g}✓${C.r} providers: ${cfg.providers.join(', ') || '(none)'}`);
175
+ }
176
+
177
+ // ================================================================== refresh ==
178
+
179
+ async function cmdRefresh() {
180
+ const t0 = Date.now();
181
+ const providers = flags.provider ? String(flags.provider).split(',') : null;
182
+ const budget = flags.budget ? Number(flags.budget) * 1000 : undefined;
183
+ let lastLine = '';
184
+ const report = await refresh({
185
+ registry: listProviders(),
186
+ providers,
187
+ full: !!flags.full,
188
+ force: !!flags.force,
189
+ strict: !!flags.strict,
190
+ deadlineMs: budget,
191
+ onProgress: (ev) => {
192
+ if (flags.quiet || flags.json) return;
193
+ if (ev.type === 'progress') {
194
+ lastLine = ` ${C.dim}${ev.provider}: ${int(ev.files)} files · ${int(ev.records)} records${C.r}`;
195
+ rewrite(lastLine);
196
+ } else if (ev.type === 'log') {
197
+ rewrite(` ${C.dim}${ev.message}${C.r}`);
198
+ }
199
+ },
200
+ });
201
+ if (lastLine) rewrite('');
202
+ if (flags.json) return console.log(JSON.stringify(report, null, 2));
203
+
204
+ console.log('');
205
+ for (const p of report.providers) {
206
+ const mark = p.status === 'ok' ? `${C.g}✓${C.r}` : p.status === 'not-detected' ? `${C.dim}○${C.r}` : p.status === 'partial' ? `${C.y}◐${C.r}` : `${C.red}✗${C.r}`;
207
+ const detail = p.status === 'not-detected'
208
+ ? `${C.dim}${p.detail || 'not detected'}${C.r}`
209
+ : `${int(p.records)} new records · ${int(p.processed ?? 0)} files read, ${int(p.skipped)} unchanged`
210
+ + (p.files ? ` of ${int(p.files)} found` : '') + ` · ${bytes(p.bytes)}`;
211
+ console.log(` ${mark} ${pad(p.id, 12)} ${detail}`);
212
+ for (const n of p.notes.slice(0, 3)) console.log(` ${C.y}${n}${C.r}`);
213
+ if (p.notes.length > 3) console.log(` ${C.dim}…${p.notes.length - 3} more notes${C.r}`);
214
+ }
215
+ console.log(`\n ${C.b}${int(report.newRecords)}${C.r} new records · ${bytes(report.bytesRead)} read · ${int(report.filesSkipped)} files skipped as unchanged · ${humanDuration(Date.now() - t0)}`);
216
+ if (report.malformed) console.log(` ${C.y}${int(report.malformed)} malformed lines skipped${C.r}`);
217
+ if (report.invalid.length) {
218
+ console.log(` ${C.red}${report.invalid.length} records failed validation:${C.r}`);
219
+ for (const v of report.invalid.slice(0, 5)) console.log(` ${v.source} ${v.id}: ${v.errors[0]}`);
220
+ }
221
+ if (report.rebuilt) {
222
+ console.log(` ${C.dim}rebuilt aggregates from ${int(report.rebuilt.records)} stored records; dropped ${int(report.rebuilt.dropped || 0)} superseded${C.r}`);
223
+ }
224
+ if (!report.done) console.log(` ${C.y}◐ time budget reached — run 'tokenflow refresh' again to continue${C.r}`);
225
+ console.log('');
226
+ }
227
+
228
+ // =================================================================== status ==
229
+
230
+ async function cmdStatus() {
231
+ // Menu-bar / wrapper fast path: one compact line from the live snapshot,
232
+ // without building a full analytics view.
233
+ if (flags.bar) {
234
+ const st = await liveStatus();
235
+ const line = barLine(st, String(flags.mode || 'auto'), String(flags.prefix || 'TF'));
236
+ return console.log(flags.json ? JSON.stringify(line, null, 2) : line.text);
237
+ }
238
+ const b = buildBundle();
239
+ if (flags.json) return console.log(JSON.stringify({ meta: b.meta, health: b.health }, null, 2));
240
+ const h = b.health;
241
+ if (!h.records) {
242
+ console.log(`\n ${C.y}No usage data yet.${C.r}`);
243
+ console.log(` Run ${C.c}tokenflow setup${C.r} then ${C.c}tokenflow refresh${C.r}, or ${C.c}tokenflow demo${C.r} to try it with synthetic data.\n`);
244
+ return;
245
+ }
246
+ const v = computeView(b, {});
247
+ console.log(`\n ${C.b}Tokenflow${C.r}${b.meta.demo ? ` ${C.red}[CONTAINS DEMO DATA]${C.r}` : ''}\n`);
248
+ const row = (k, val) => console.log(` ${pad(k, 16)} ${val}`);
249
+ row('Records:', int(h.records));
250
+ row('Providers:', `${h.providers} ${C.dim}${v.dimensions.providers.slice(0, 4).map((p) => p.key).join(', ')}${C.r}`);
251
+ row('Models:', `${h.models} ${C.dim}${v.dimensions.models.slice(0, 3).map((m) => m.key).join(', ')}${C.r}`);
252
+ row('Date Range:', `${longDate(h.coverage.from)} → ${longDate(h.coverage.to)}`);
253
+ row('Total tokens:', `${compact(v.totals.total)} ${C.dim}(${int(v.totals.total)})${C.r}`);
254
+ row(' input', `${compact(v.totals.in)} ${pct(v.composition.shares.input)}`);
255
+ row(' output', `${compact(v.totals.out)} ${pct(v.composition.shares.output)}`);
256
+ row(' cache', `${compact(v.totals.cr + v.totals.cw)} ${pct(v.composition.shares.cache)}`);
257
+ row('Sessions:', int(h.sessions));
258
+ row('Active days:', `${v.averages.activeDays} of ${v.daily.length}`);
259
+ row('Avg / day:', compact(v.averages.perActiveDay));
260
+ row('Peak day:', v.peaks.peakDay ? `${compact(v.peaks.peakDay.total)} ${longDate(v.peaks.peakDay.date)}` : '—');
261
+ row('Est. cost:', v.cost.estimated === null
262
+ ? `${C.dim}not available (no configured pricing)${C.r}`
263
+ : `${usd(v.cost.estimated)} ${C.dim}est. · covers ${pct(v.cost.coverage)} of requests${C.r}`);
264
+ if (v.cost.measured !== null) {
265
+ row('Measured cost:', `${usd(v.cost.measured)} ${C.dim}reported by the source/gateway itself${C.r}`);
266
+ }
267
+ row('Last Refresh:', relativeTime(h.lastRefresh));
268
+ const gradeColor = h.grade === 'Excellent' ? C.g : h.grade === 'Good' ? C.c : C.y;
269
+ row('Data Health:', `${gradeColor}${h.grade}${C.r} ${C.dim}· ${pct(h.missingTokenFieldRate)} of token fields unreported by source${C.r}`);
270
+ console.log('');
271
+ if (v.insights.length) {
272
+ console.log(` ${C.b}Insights${C.r}`);
273
+ for (const i of v.insights.slice(0, 6)) console.log(` ${i.icon} ${wrap(i.text, 92, 6)}`);
274
+ console.log('');
275
+ }
276
+ if (v.cost.unpriced.length) {
277
+ console.log(` ${C.dim}${v.cost.unpriced.length} model(s) have no configured price. Run 'tokenflow pricing' to add rates.${C.r}\n`);
278
+ }
279
+ }
280
+
281
+ // ================================================================ dashboard ==
282
+
283
+ async function cmdDashboard() {
284
+ const port = Number(flags.port) || 7799;
285
+ const host = flags.host || '127.0.0.1';
286
+ const b = buildBundle();
287
+ const s = await startServer({ port, host, token: flags.token === false ? false : undefined });
288
+ console.log(`\n ${C.b}Tokenflow${C.r}`);
289
+ console.log(` ${C.c}${s.url}${C.r}`);
290
+ console.log(` ${C.dim}${int(b.health.records)} records · ${b.health.coverage.from ? `${shortDate(b.health.coverage.from)} → ${shortDate(b.health.coverage.to)}` : 'no data'} · loopback only, nothing leaves this machine${C.r}`);
291
+ if (!b.health.records) console.log(` ${C.y}No data yet — click ↻ Refresh in the dashboard, or run 'tokenflow refresh'.${C.r}`);
292
+ console.log(` ${C.dim}Ctrl+C to stop${C.r}\n`);
293
+ if (flags.open !== false && flags['no-open'] !== true) tryOpen(s.url);
294
+ await new Promise(() => {});
295
+ }
296
+
297
+ /**
298
+ * The "I just want to look at it" command: bring the data up to date, refresh
299
+ * the offline snapshot beside it, then serve and open the live dashboard.
300
+ *
301
+ * Refresh is time-budgeted and resumable, so this loops until the engine
302
+ * reports done instead of assuming one pass is enough — a first run over a
303
+ * multi-gigabyte log directory legitimately takes several passes.
304
+ */
305
+ async function cmdUp() {
306
+ const budget = Number(flags.budget) || 60;
307
+ const maxPasses = Number(flags.passes) || 20;
308
+ let pass = 0;
309
+ let total = 0;
310
+ if (flags.refresh !== false && flags['no-refresh'] !== true) {
311
+ for (;;) {
312
+ pass++;
313
+ const report = await refresh({
314
+ registry: listProviders(),
315
+ deadlineMs: budget * 1000,
316
+ onProgress: (ev) => {
317
+ if (ev.type === 'progress') rewrite(` ${C.dim}pass ${pass}: ${int(ev.files)} files · ${int(ev.records)} records${C.r}`);
318
+ },
319
+ });
320
+ total += report.newRecords;
321
+ rewrite('');
322
+ const unreachable = report.providers.filter((x) => x.status === 'error' || x.status === 'detect-error');
323
+ for (const u of unreachable) console.log(` ${C.y}! ${u.id}: ${u.notes[0] || 'failed'}${C.r}`);
324
+ if (report.done) break;
325
+ if (pass >= maxPasses) {
326
+ console.log(` ${C.y}◐ stopped after ${pass} passes — run 'tokenflow refresh' again to finish the backlog${C.r}`);
327
+ break;
328
+ }
329
+ }
330
+ console.log(` ${C.g}✓${C.r} data current ${C.dim}(${int(total)} new record(s) in ${pass} pass(es))${C.r}`);
331
+ }
332
+
333
+ // Keep the offline copy next to the live one: whoever opens the .html file
334
+ // later gets the same numbers, and its freshness bar has something recent
335
+ // to report.
336
+ if (flags.snapshot !== false && flags['no-snapshot'] !== true) {
337
+ const file = path.join(process.cwd(), typeof flags.snapshot === 'string' ? flags.snapshot : 'tokenflow-dashboard.html');
338
+ try {
339
+ const { html, stats } = buildSnapshot({ maxRecords: Number(flags.maxRecords) || 20000 });
340
+ fs.writeFileSync(file, html);
341
+ console.log(` ${C.g}✓${C.r} offline snapshot refreshed ${C.dim}${file} · ${bytes(stats.bytes)}${C.r}`);
342
+ } catch (err) {
343
+ console.log(` ${C.y}! snapshot skipped: ${err.message}${C.r}`);
344
+ }
345
+ }
346
+
347
+ if (flags.serve === false || flags['no-serve'] === true) {
348
+ console.log(` ${C.dim}not serving (--no-serve). Open the offline file, or run 'tokenflow dashboard'.${C.r}\n`);
349
+ return undefined;
350
+ }
351
+ return cmdDashboard();
352
+ }
353
+
354
+ function tryOpen(url) {
355
+ const cmds = process.platform === 'darwin' ? ['open'] : process.platform === 'win32' ? ['cmd', '/c', 'start', ''] : ['xdg-open'];
356
+ import('node:child_process').then(({ spawn }) => {
357
+ try {
358
+ spawn(cmds[0], [...cmds.slice(1), url], { stdio: 'ignore', detached: true }).unref();
359
+ } catch { /* headless: the URL is printed above */ }
360
+ });
361
+ }
362
+
363
+ // =================================================================== export ==
364
+
365
+ async function cmdExport() {
366
+ const outDir = flags.out ? String(flags.out) : process.cwd();
367
+ if (flags.html !== undefined) {
368
+ const file = typeof flags.html === 'string' ? flags.html : path.join(outDir, exportFilename('tokenflow', new Date(), 'html'));
369
+ const { html, stats } = buildSnapshot({ maxRecords: Number(flags.maxRecords) || 20000 });
370
+ fs.writeFileSync(file, html);
371
+ console.log(`${C.g}✓${C.r} ${file} ${C.dim}${bytes(stats.bytes)} · ${int(stats.cubeRows)} cube rows · ${int(stats.records)} records${stats.recordsTruncated ? ' (capped)' : ''} · fully offline${C.r}`);
372
+ return;
373
+ }
374
+ const file = typeof flags.csv === 'string' ? flags.csv : path.join(outDir, exportFilename());
375
+ const filter = flags.all ? {} : {
376
+ from: flags.from || null, to: flags.to || null,
377
+ provider: flags.provider, model: flags.model, client: flags.client,
378
+ interface: flags.interface, project: flags.project,
379
+ };
380
+ const fd = fs.openSync(file, 'w');
381
+ let n = 0;
382
+ try {
383
+ n = streamRecordsCsv((chunk) => fs.writeSync(fd, chunk), filter);
384
+ } finally {
385
+ fs.closeSync(fd);
386
+ }
387
+ console.log(`${C.g}✓${C.r} ${file} ${C.dim}${int(n)} records${flags.all ? ' (all data)' : ' (current filter)'}${C.r}`);
388
+ }
389
+
390
+ // ================================================================== pricing ==
391
+
392
+ async function cmdPricing() {
393
+ const p = paths();
394
+ const cur = readJson(p.pricing, { models: {} });
395
+ if (flags.set) {
396
+ // --set "claude-opus-5=15,75,1.5,18.75" (input,output,cacheRead,cacheWrite)
397
+ for (const spec of [].concat(flags.set)) {
398
+ const [model, csv] = String(spec).split('=');
399
+ if (!model || !csv) throw new Error('usage: --set "<model>=<input>,<output>[,<cacheRead>[,<cacheWrite>]]"');
400
+ const [i, o, cr, cw] = csv.split(',').map((x) => (x === '' ? null : Number(x)));
401
+ cur.models = cur.models || {};
402
+ cur.models[model] = { in: i, out: o, ...(cr !== undefined && cr !== null ? { cacheRead: cr } : {}), ...(cw !== undefined && cw !== null ? { cacheWrite: cw } : {}) };
403
+ console.log(`${C.g}✓${C.r} ${model}: $${i}/1M in, $${o}/1M out${cr ? `, $${cr} cache read` : ''}${cw ? `, $${cw} cache write` : ''}`);
404
+ }
405
+ cur.updatedAt = new Date().toISOString();
406
+ writeJson(p.pricing, cur);
407
+ console.log(`${C.dim}saved to ${p.pricing} — run 'tokenflow refresh --full' to re-cost history${C.r}`);
408
+ return;
409
+ }
410
+ if (flags.unset) {
411
+ delete (cur.models || {})[String(flags.unset)];
412
+ writeJson(p.pricing, cur);
413
+ return console.log(`${C.g}✓${C.r} removed ${flags.unset}`);
414
+ }
415
+ if (flags.sources) {
416
+ console.log(`\n ${C.b}Where the built-in rates come from${C.r} ${C.dim}table ${PRICING_TABLE_VERSION}${C.r}\n`);
417
+ for (const [key, src] of Object.entries(PRICING_SOURCES)) {
418
+ const tag = src.confidence === 'official' ? `${C.g}official${C.r}`
419
+ : src.confidence === 'third-party' ? `${C.y}third-party${C.r}` : `${C.c}official (historical)${C.r}`;
420
+ console.log(` ${pad(key, 20)} ${tag} ${C.dim}fetched ${src.fetched}${C.r}`);
421
+ console.log(` ${' '.repeat(20)} ${C.dim}${src.url}${C.r}`);
422
+ if (src.note) console.log(` ${' '.repeat(20)} ${C.y}${wrap(src.note, 76, 22)}${C.r}`);
423
+ }
424
+ console.log(`\n ${C.b}Service-tier multipliers${C.r} ${C.dim}applied per request from metadata.service_tier${C.r}`);
425
+ for (const [prov, tiers] of Object.entries(TIER_MULTIPLIERS)) {
426
+ const shown = Object.entries(tiers).filter(([, v]) => v !== 1).map(([k, v]) => `${k}=${v}x`);
427
+ console.log(` ${pad(prov, 20)} ${shown.length ? shown.join(' ') : C.dim + 'all tiers 1x' + C.r}`);
428
+ }
429
+ console.log(`\n ${C.y}Not applied:${C.r} long-context premium tiers (Anthropic >200K, OpenAI long-context).`);
430
+ console.log(` ${C.dim}They need a per-request prompt size plus a per-model threshold and premium, which`);
431
+ console.log(` are not uniformly published. A long-context-heavy workload is therefore UNDER-estimated.${C.r}\n`);
432
+ return;
433
+ }
434
+
435
+ const b = buildBundle();
436
+ const v = computeView(b, {});
437
+ const book = buildPriceBook(readJson(p.pricing, {}));
438
+ console.log(`\n ${C.b}Pricing${C.r} ${C.dim}built-in table ${PRICING_TABLE_VERSION} · overrides in ${p.pricing}${C.r}\n`);
439
+ console.log(` ${pad('MODEL', 30)} ${pad('TOKENS', 9)} ${pad('EST. COST', 12)} ${pad('$/1M', 9)} SOURCE`);
440
+ for (const m of v.dimensions.models) {
441
+ const entry = book.lookup(m.key, m.provider || undefined) || book.lookup(m.key, 'unknown');
442
+ const ok = m.cost !== null;
443
+ const per1m = ok && m.total ? usd(m.cost / (m.total / 1e6)) : '—';
444
+ const src = entry ? (entry.origin === 'user' ? `${C.c}your override${C.r}` : `${C.dim}${entry.src}${C.r}`) : `${C.y}unpriced${C.r}`;
445
+ console.log(` ${pad(m.key, 30)} ${pad(compact(m.total), 9)} ${pad(ok ? usd(m.cost) : '—', 12)} ${pad(per1m, 9)} ${src}`);
446
+ }
447
+ console.log(`\n ${C.dim}provenance: tokenflow pricing --sources${C.r}`);
448
+ console.log(` ${C.dim}add a rate: tokenflow pricing --set "<model>=<input$/1M>,<output$/1M>[,<cacheRead>[,<cacheWrite>]]"${C.r}`);
449
+ console.log(` ${C.dim}or use the Pricing dialog in the dashboard.${C.r}\n`);
450
+ }
451
+
452
+ // =================================================================== import ==
453
+
454
+ async function cmdImport() {
455
+ const file = argv[1];
456
+ if (!file) {
457
+ console.log(`\n ${C.b}Generic import${C.r}`);
458
+ console.log(' usage: tokenflow import <file> [--name <mapping>] [--format csv|tsv|json|jsonl|sqlite] [--table t]');
459
+ console.log(' [--field <schemaField>=<sourceColumn>]... [--default <field>=<value>]...');
460
+ console.log(' [--timestamp-format iso|epoch_ms|epoch_s] [--dry-run]\n');
461
+ console.log(` mappable fields: ${C.dim}${MAPPABLE_FIELDS.join(', ')}${C.r}`);
462
+ console.log(` ${C.dim}A mapping is saved to ${paths().mappings}/<name>.json and reused on every later refresh.${C.r}\n`);
463
+ return;
464
+ }
465
+ const abs = path.resolve(file.startsWith('~') ? path.join(os.homedir(), file.slice(1)) : file);
466
+ if (!fs.existsSync(abs)) throw new Error(`no such file: ${abs}`);
467
+ const name = String(flags.name || path.basename(abs).replace(/\.[^.]+$/, '')).replace(/[^\w.-]/g, '_');
468
+ const fields = {};
469
+ for (const f of [].concat(flags.field || [])) {
470
+ const [k, v] = String(f).split('=');
471
+ if (!MAPPABLE_FIELDS.includes(k)) throw new Error(`"${k}" is not a mappable field. Options: ${MAPPABLE_FIELDS.join(', ')}`);
472
+ fields[k] = v;
473
+ }
474
+ const defaults = {};
475
+ for (const d of [].concat(flags.default || [])) {
476
+ const [k, v] = String(d).split('=');
477
+ defaults[k] = v;
478
+ }
479
+
480
+ // Suggest a mapping from the header row when the user gave none.
481
+ if (!Object.keys(fields).length) {
482
+ const cols = sniffColumns(abs, flags.format);
483
+ console.log(`\n ${C.b}Columns found${C.r}: ${cols.join(', ') || '(none)'}\n`);
484
+ const guess = guessMapping(cols);
485
+ for (const [k, v] of Object.entries(guess)) console.log(` ${C.dim}--field ${k}=${v}${C.r}`);
486
+ if (!Object.keys(guess).length) {
487
+ throw Object.assign(new Error('could not infer a mapping'), { hint: 'pass --field <schemaField>=<column> for at least timestamp and the token fields' });
488
+ }
489
+ Object.assign(fields, guess);
490
+ console.log(`\n ${C.y}Using the inferred mapping above.${C.r} Re-run with explicit --field flags to change it.\n`);
491
+ }
492
+ if (!fields.timestamp) throw new Error('a timestamp field is required (--field timestamp=<column>)');
493
+
494
+ const mapping = {
495
+ name,
496
+ format: flags.format || undefined,
497
+ files: [abs],
498
+ table: flags.table || undefined,
499
+ query: flags.query || undefined,
500
+ timestampFormat: flags['timestamp-format'] || undefined,
501
+ fields,
502
+ defaults,
503
+ };
504
+
505
+ const gen = getProvider('generic');
506
+ const sample = sniffRows(abs, flags.format, 5).map((r) => gen.normalize(r, mapping));
507
+ console.log(` ${C.b}Preview${C.r} (${sample.length} rows)`);
508
+ for (const s of sample) {
509
+ if (!s) { console.log(` ${C.red}row skipped: unparseable timestamp${C.r}`); continue; }
510
+ console.log(` ${s.timestamp} ${pad(String(s.model), 24)} in=${fmtNull(s.input_tokens)} out=${fmtNull(s.output_tokens)} cacheR=${fmtNull(s.cache_read_tokens)}`);
511
+ }
512
+ console.log(` ${C.dim}"n/a" means the mapping leaves that field unset — it is stored as not-available, never as 0.${C.r}`);
513
+ if (flags['dry-run']) return console.log(`\n ${C.y}dry run — nothing saved${C.r}\n`);
514
+
515
+ ensureDirs();
516
+ const dest = path.join(paths().mappings, `${name}.json`);
517
+ writeJson(dest, mapping);
518
+ const cfg = loadConfig();
519
+ if (!cfg.providers.includes('generic')) { cfg.providers.push('generic'); saveConfig(cfg); }
520
+ console.log(`\n${C.g}✓${C.r} saved mapping ${dest}`);
521
+ console.log(` running refresh for the generic provider…\n`);
522
+ argv[0] = 'refresh';
523
+ flags.provider = 'generic';
524
+ await cmdRefresh();
525
+ }
526
+
527
+ function fmtNull(v) {
528
+ return v === null || v === undefined ? `${C.dim}n/a${C.r}` : String(v);
529
+ }
530
+
531
+ function sniffColumns(file, fmt) {
532
+ const rows = sniffRows(file, fmt, 1);
533
+ return rows.length ? Object.keys(rows[0]) : [];
534
+ }
535
+
536
+ function sniffRows(file, fmt, n) {
537
+ const ext = (fmt || path.extname(file).slice(1)).toLowerCase();
538
+ if (ext === 'jsonl' || ext === 'ndjson') {
539
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(0, n);
540
+ return lines.map((l) => { try { return JSON.parse(l); } catch { return {}; } });
541
+ }
542
+ if (ext === 'json') {
543
+ const d = JSON.parse(fs.readFileSync(file, 'utf8'));
544
+ const arr = Array.isArray(d) ? d : (d.records || d.data || d.usage || []);
545
+ return arr.slice(0, n);
546
+ }
547
+ if (ext === 'db' || ext === 'sqlite' || ext === 'sqlite3') {
548
+ return [];
549
+ }
550
+ const head = fs.readFileSync(file, 'utf8').split('\n').slice(0, n + 1).join('\n');
551
+ return parseDelimited(head, ext === 'tsv' ? '\t' : ',').slice(0, n);
552
+ }
553
+
554
+ function guessMapping(cols) {
555
+ const pick = (...pats) => cols.find((c) => pats.some((p) => new RegExp(p, 'i').test(c)));
556
+ const out = {};
557
+ const put = (k, v) => { if (v) out[k] = v; };
558
+ put('timestamp', pick('^(timestamp|ts|created_?at|date_?time|time)$', 'timestamp', 'created'));
559
+ put('model', pick('^model', 'model'));
560
+ put('provider', pick('^provider$', 'vendor'));
561
+ put('input_tokens', pick('^(input|prompt)_?tokens$', 'prompt_tokens', 'input_tokens'));
562
+ put('output_tokens', pick('^(output|completion|generated)_?tokens$', 'completion_tokens'));
563
+ put('cache_read_tokens', pick('cache_?read', 'cached_?(input_?)?tokens', 'cache_?hit'));
564
+ put('cache_write_tokens', pick('cache_?(write|creation)'));
565
+ put('reasoning_tokens', pick('reasoning', 'thinking'));
566
+ put('estimated_cost', pick('^cost', 'cost_usd', 'total_cost', 'amount'));
567
+ put('session_id', pick('session', 'conversation_?id', 'generation_?id', '^id$'));
568
+ put('project', pick('^project', 'repo'));
569
+ put('client', pick('^client$', '^app$', 'application'));
570
+ return out;
571
+ }
572
+
573
+ // =================================================================== config ==
574
+
575
+ async function cmdConfig() {
576
+ const action = argv[1] || 'show';
577
+ const p = paths();
578
+ if (action === 'path') return console.log(p.root);
579
+ if (action === 'show') {
580
+ return console.log(stringifyYaml(loadConfig()));
581
+ }
582
+ if (action === 'export') {
583
+ const dest = argv[2] || path.join(process.cwd(), `tokenflow-config-${new Date().toISOString().slice(0, 10)}.json`);
584
+ const payload = {
585
+ exportedAt: new Date().toISOString(),
586
+ config: loadConfig(),
587
+ pricing: readJson(p.pricing, {}),
588
+ mappings: Object.fromEntries((safeReaddir(p.mappings)).map((f) => [f, readJson(path.join(p.mappings, f), null)])),
589
+ };
590
+ writeJson(dest, payload);
591
+ return console.log(`${C.g}✓${C.r} ${dest} ${C.dim}(config + pricing + import mappings; no usage data)${C.r}`);
592
+ }
593
+ if (action === 'import') {
594
+ const src = argv[2];
595
+ if (!src) throw new Error('usage: tokenflow config import <file>');
596
+ const d = readJson(path.resolve(src), null);
597
+ if (!d || !d.config) throw new Error('not a config export');
598
+ ensureDirs();
599
+ saveConfig(merge(DEFAULT_CONFIG, d.config));
600
+ if (d.pricing) writeJson(p.pricing, d.pricing);
601
+ for (const [name, m] of Object.entries(d.mappings || {})) if (m) writeJson(path.join(p.mappings, name), m);
602
+ return console.log(`${C.g}✓${C.r} imported config, pricing and ${Object.keys(d.mappings || {}).length} mapping(s) into ${p.root}`);
603
+ }
604
+ throw new Error('usage: tokenflow config <show|path|export|import>');
605
+ }
606
+
607
+ function safeReaddir(d) {
608
+ try { return fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return []; }
609
+ }
610
+
611
+ // ===================================================================== demo ==
612
+
613
+ async function cmdDemo() {
614
+ process.env.TOKENFLOW_DEMO = '1';
615
+ const cfg = loadConfig();
616
+ cfg.providers = ['mock'];
617
+ cfg.sources.mock = { days: Number(flags.days) || 160, seed: Number(flags.seed) || 20260814 };
618
+ saveConfig(cfg);
619
+ console.log(`\n ${C.red}Generating SYNTHETIC DEMO DATA${C.r} ${C.dim}(clearly labelled everywhere in the UI)${C.r}\n`);
620
+ argv[0] = 'refresh';
621
+ flags.full = true;
622
+ flags.provider = 'mock';
623
+ await cmdRefresh();
624
+ // `--no-serve` / `--no-dashboard` keep this non-interactive, which is what CI
625
+ // and scripted setups need.
626
+ if (flags.dashboard !== false && flags['no-dashboard'] !== true && flags.serve !== false && flags['no-serve'] !== true) {
627
+ flags.port = flags.port || 7799;
628
+ await cmdDashboard();
629
+ } else {
630
+ console.log(` Next: ${C.c}tokenflow dashboard${C.r}\n`);
631
+ }
632
+ }
633
+
634
+ // ================================================================= validate ==
635
+
636
+ async function cmdValidate() {
637
+ const store = new Store();
638
+ let n = 0;
639
+ let bad = 0;
640
+ const errors = new Map();
641
+ const { decodeRecord } = await import('../src/core/store.js');
642
+ store.scanRecords((o) => {
643
+ n++;
644
+ const r = decodeRecord(o);
645
+ const v = validateUsage(r);
646
+ if (!v.ok) {
647
+ bad++;
648
+ for (const e of v.errors) errors.set(e, (errors.get(e) || 0) + 1);
649
+ }
650
+ });
651
+ console.log(`\n checked ${int(n)} records · ${bad ? `${C.red}${int(bad)} invalid${C.r}` : `${C.g}all valid${C.r}`}`);
652
+ for (const [e, c] of [...errors].sort((a, b) => b[1] - a[1]).slice(0, 12)) console.log(` ${C.y}${int(c)}×${C.r} ${e}`);
653
+ console.log('');
654
+ if (bad) process.exitCode = 1;
655
+ }
656
+
657
+ // =================================================================== doctor ==
658
+
659
+ async function cmdDoctor() {
660
+ const p = paths();
661
+ const cfg = loadConfig();
662
+ console.log(`\n ${C.b}Environment${C.r}`);
663
+ console.log(` node ${process.version} ${major() >= 22 ? `${C.g}ok${C.r}` : `${C.red}needs >= 22.5${C.r}`}`);
664
+ let sqliteOk = false;
665
+ try { const { sqliteAvailable } = await import('../src/core/sqlite.js'); sqliteOk = sqliteAvailable(); } catch { /* unavailable */ }
666
+ console.log(` node:sqlite ${sqliteOk ? `${C.g}available${C.r}` : `${C.y}unavailable — SQLite sources will be skipped${C.r}`}`);
667
+ console.log(` platform ${process.platform}/${process.arch}`);
668
+ console.log(` timezone ${cfg.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}`);
669
+ console.log(`\n ${C.b}Paths${C.r}`);
670
+ for (const [k, v] of Object.entries(p)) {
671
+ const exists = fs.existsSync(v);
672
+ console.log(` ${pad(k, 12)} ${exists ? `${C.g}✓${C.r}` : `${C.dim}·${C.r}`} ${v}`);
673
+ }
674
+ const store = new Store();
675
+ const shards = store.listShards();
676
+ let raw = 0;
677
+ for (const s of shards) { try { raw += fs.statSync(path.join(p.records, s)).size; } catch { /* gone */ } }
678
+ console.log(`\n ${C.b}Store${C.r}`);
679
+ console.log(` shards ${shards.length} (${bytes(raw)} of request-level records)`);
680
+ console.log(` cube ${int(store.cube().rows.length)} rows`);
681
+ console.log(` sessions ${int(Object.keys(store.sessions().rows).length)}`);
682
+ console.log(` stale gens ${(store.state.stale || []).length}${(store.state.stale || []).length ? ` ${C.y}run 'tokenflow compact'${C.r}` : ''}`);
683
+ console.log(`\n ${C.b}Providers${C.r}`);
684
+ await cmdProviders();
685
+ console.log(` ${C.dim}Troubleshooting guide: docs/troubleshooting.md${C.r}\n`);
686
+ }
687
+
688
+ function major() {
689
+ return Number(process.version.slice(1).split('.')[0]) + Number(process.version.slice(1).split('.')[1]) / 100;
690
+ }
691
+
692
+ // ================================================================== compact ==
693
+
694
+ async function cmdCompact() {
695
+ const { compactShards } = await import('../src/core/store.js');
696
+ const { rebuildAggregates } = await import('../src/core/ingest.js');
697
+ const store = new Store();
698
+ const stale = store.staleSet().size;
699
+ if (!stale && !flags.recount) {
700
+ console.log(` ${C.g}nothing to compact${C.r} — no superseded records.`);
701
+ console.log(` ${C.dim}pass --recount to rebuild the aggregates and re-derive the record counts anyway.${C.r}`);
702
+ return;
703
+ }
704
+ if (stale) {
705
+ const res = compactShards(store);
706
+ console.log(` ${C.g}✓${C.r} compacted ${res.shards} shard(s): kept ${int(res.kept)}, dropped ${int(res.dropped)}.`);
707
+ }
708
+ const before = store.state.counters?.records || 0;
709
+ const rb = rebuildAggregates(store);
710
+ // Counts are derived from the records that actually survived, so a store that
711
+ // has been restored, re-ingested or compacted stops reporting a lifetime
712
+ // total in place of its real size.
713
+ store.state.counters.records = rb.records;
714
+ for (const id of Object.keys(store.state.sources)) store.state.sources[id].records = rb.bySource[id] || 0;
715
+ store.saveCube();
716
+ store.saveSessions();
717
+ store.saveActivity();
718
+ store.saveState();
719
+ console.log(` ${C.g}✓${C.r} rebuilt aggregates from ${int(rb.records)} records.`);
720
+ if (before !== rb.records) console.log(` ${C.g}✓${C.r} re-derived record counts ${C.dim}(state said ${int(before)}, the store holds ${int(rb.records)})${C.r}`);
721
+ for (const [id, n] of Object.entries(rb.bySource).sort((a, b) => b[1] - a[1])) console.log(` ${id.padEnd(12)} ${int(n).padStart(9)}`);
722
+ }
723
+
724
+ async function cmdRestore() {
725
+ const file = argv[1] && !argv[1].startsWith('-') ? argv[1] : (typeof flags.file === 'string' ? flags.file : null);
726
+ if (!file) {
727
+ throw Object.assign(new Error('usage: tokenflow restore <full-export.csv>'), {
728
+ hint: 'Rebuilds the store from a `tokenflow export --csv --all` file and re-prices every estimate with the current table.',
729
+ });
730
+ }
731
+ const store = new Store();
732
+ const held = store.state.counters?.records || 0;
733
+ if (held > 0 && !flags.yes) {
734
+ throw Object.assign(new Error(`this replaces the ${int(held)} record(s) already in the store`), {
735
+ hint: 'A restore is a whole-dataset operation, not an increment. Re-run with --yes to confirm.',
736
+ });
737
+ }
738
+ const { restoreFromCsv } = await import('../src/core/restore.js');
739
+ console.log(`\n restoring from ${C.b}${file}${C.r}${flags['no-reprice'] ? '' : ' · re-pricing estimates with the current table'}`);
740
+ const r = restoreFromCsv(path.resolve(file), {
741
+ reprice: !flags['no-reprice'],
742
+ onProgress: (e) => {
743
+ if (e.type === 'progress') process.stdout.write(`\r ${C.dim}${int(e.records)} records…${C.r}`);
744
+ },
745
+ });
746
+ process.stdout.write('\r\x1b[K');
747
+ console.log(` ${C.g}✓${C.r} ${int(r.records)} records restored ${C.dim}in ${(r.durationMs / 1000).toFixed(1)}s${C.r}`);
748
+ for (const [id, n] of Object.entries(r.bySource).sort((a, b) => b[1] - a[1])) {
749
+ console.log(` ${id.padEnd(12)} ${int(n).padStart(9)}`);
750
+ }
751
+ console.log(` ${C.dim}re-priced ${int(r.repriced)} · measured costs kept ${int(r.measuredKept)} · no price found ${int(r.unpriced)} · table ${r.pricingVersion}${C.r}`);
752
+ if (r.malformed || r.dropped.noTimestamp || r.dropped.noDate) {
753
+ console.log(` ${C.y}!${C.r} skipped ${int(r.malformed)} malformed row(s), ${int(r.dropped.noTimestamp + r.dropped.noDate)} row(s) without a usable timestamp`);
754
+ }
755
+ console.log(` ${C.dim}Per-record metadata (working directory, audit trail) is not part of a CSV export and is not restored.${C.r}`);
756
+ console.log(` ${C.dim}Restored records are provisional: a refresh that reaches a source's real logs supersedes them automatically.${C.r}\n`);
757
+ }
758
+
759
+ async function cmdReset() {
760
+ if (!flags.yes) {
761
+ throw Object.assign(new Error('this deletes all ingested data'), { hint: `re-run with --yes to confirm. Config and pricing are kept. Data home: ${paths().root}` });
762
+ }
763
+ const p = paths();
764
+ fs.rmSync(p.data, { recursive: true, force: true });
765
+ ensureDirs();
766
+ console.log(`${C.g}✓${C.r} cleared ${p.data} (config and pricing kept)`);
767
+ }
768
+
769
+ // ==================================================================== live ==
770
+
771
+ /** Fast path for live commands: watch snapshot when fresh, else compute now. */
772
+ async function liveStatus() {
773
+ const { status } = currentStatus();
774
+ return status;
775
+ }
776
+
777
+ /**
778
+ * `tokenflow watch` — keep the store + status file current in the background.
779
+ *
780
+ * --interval <s> seconds between cycles (config: watch.intervalSeconds)
781
+ * --once one refresh+status cycle and exit (cron-friendly)
782
+ * --notify OS notifications on threshold crossings (also: config)
783
+ * --status is a watcher running? how fresh is it?
784
+ * --stop stop a running watcher
785
+ */
786
+ async function cmdWatch() {
787
+ if (flags.stop) {
788
+ const r = stopWatch();
789
+ console.log(r.stopped ? `${C.g}✓${C.r} stopped watcher ${C.dim}(pid ${r.pid})${C.r}` : `${C.dim}○ ${r.reason}${C.r}`);
790
+ return;
791
+ }
792
+ if (flags.status) {
793
+ const running = watchIsRunning();
794
+ const st = readLiveStatus();
795
+ console.log(` watcher ${running ? `${C.g}running${C.r}` : `${C.dim}not running${C.r}`}`);
796
+ // Identity lines only describe a live process — a dead watcher's leftovers
797
+ // are history, not status.
798
+ if (running && st?.watcher?.pid != null) {
799
+ console.log(` pid ${st.watcher.pid} · every ${st.watcher.intervalSeconds ?? '?'}s · ${int(st.watcher.cycles)} cycle(s)`);
800
+ }
801
+ const lastErr = st?.lastError;
802
+ if (lastErr) console.log(` ${C.y}last error${C.r} ${relativeTime(lastErr.at)}: ${lastErr.message}`);
803
+ if (st?.freshness) {
804
+ const fresh = withComputedFreshness(st).freshness;
805
+ console.log(` data ${fresh.stale ? `${C.y}stale${C.r}` : `${C.g}fresh${C.r}`} ${st.freshness.lastRefresh ? `· updated ${relativeTime(st.freshness.lastRefresh)}` : '(never refreshed)'}`);
806
+ console.log(` status ${paths().status}`);
807
+ }
808
+ if (!running && !flags.json) {
809
+ console.log(`\n ${C.dim}start one: tokenflow watch${C.r}`);
810
+ }
811
+ return;
812
+ }
813
+
814
+ const cfg = loadConfig();
815
+ const interval = Number(flags.interval) || cfg.watch?.intervalSeconds || 120;
816
+ const notifyOn = flags.notify === true || !!cfg.watch?.notifications;
817
+
818
+ if (flags.once) {
819
+ const r = await runCycle({ config: cfg, notifications: notifyOn });
820
+ if (r.skipped) return console.log(`${C.dim}○ another cycle is already running${C.r}`);
821
+ const st = readLiveStatus();
822
+ console.log(`${C.g}✓${C.r} cycle done · ${barLine(st).text.replace(/^TF /, '')} · status written`);
823
+ for (const tr of r.transitions) console.log(` ${C.y}!${C.r} ${tr.title}`);
824
+ return;
825
+ }
826
+
827
+ process.on('SIGINT', () => { releaseWatchLock(); process.exit(0); });
828
+ process.on('SIGTERM', () => { releaseWatchLock(); process.exit(0); });
829
+ console.log(`${C.b}Tokenflow watcher${C.r} ${C.dim}· every ${interval}s${notifyOn ? ' · notifications on' : ''} · Ctrl+C to stop${C.r}`);
830
+ await startWatch({
831
+ intervalSeconds: interval,
832
+ notifications: notifyOn,
833
+ config: cfg,
834
+ onCycle: (r) => {
835
+ if (process.stdout.isTTY && !r?.error) {
836
+ const line = r?.report ? `✓ refreshed (${int(r.report.newRecords)} new)` : 'cycle complete';
837
+ rewrite(` ${C.dim}${line}${C.r}`);
838
+ }
839
+ },
840
+ });
841
+ }
842
+
843
+ function printUsageRows(rows, { demo = false } = {}) {
844
+ const row = (k, u) => {
845
+ const c = u.cost ?? u.costMeasured;
846
+ console.log(` ${pad(k, 12)} ${pad(compact(u.tokens?.total ?? 0), 9)} ${pad(int(u.requests ?? 0), 8)} sessions=${u.sessions ?? '—'} cost=${c != null ? usd(c) : `${C.dim}n/a${C.r}`}`);
847
+ };
848
+ console.log(`\n ${C.b}Usage${C.r}${demo ? ` ${C.red}[demo]${C.r}` : ''}`);
849
+ row('Today', rows.today);
850
+ row('Yesterday', rows.yesterday);
851
+ row('Week', rows.weekToDate);
852
+ row('Month', rows.monthToDate);
853
+ const cov = rows.coverage;
854
+ if (cov?.from) console.log(` ${C.dim}coverage ${cov.from} → ${cov.to}${C.r}\n`);
855
+ }
856
+
857
+ /** `tokenflow usage` — the token/cost answer for today, week, month. */
858
+ async function cmdUsage() {
859
+ const st = await liveStatus();
860
+ const payload = { freshness: st.freshness, usage: st.usage, providersToday: st.providersToday, modelsToday: st.modelsToday };
861
+ if (flags.json) return console.log(JSON.stringify(payload, null, 2));
862
+ if (!st.health.records) return console.log(`\n ${C.y}No usage data yet.${C.r} Run ${C.c}tokenflow setup${C.r}, then ${C.c}tokenflow refresh${C.r}.\n`);
863
+ printUsageRows({ ...st.usage, coverage: st.health.coverage }, { demo: st.demo });
864
+ if (st.providersToday.length) {
865
+ console.log(` ${C.dim}today's top: ${st.providersToday.map((p) => `${p.key} ${compact(p.tokens)}`).join(' · ')}${C.r}\n`);
866
+ }
867
+ }
868
+
869
+ /** `tokenflow cost` — estimated vs measured spend, who costs what. */
870
+ async function cmdCost() {
871
+ const st = await liveStatus();
872
+ if (flags.json) {
873
+ return console.log(JSON.stringify({ cost: { today: pick2(st.usage.today), weekToDate: pick2(st.usage.weekToDate), monthToDate: pick2(st.usage.monthToDate) }, providersToday: st.providersToday, modelsToday: st.modelsToday }, null, 2));
874
+ }
875
+ if (!st.health.records) return console.log(`\n ${C.y}No usage data yet.${C.r}\n`);
876
+ console.log(`\n ${C.b}Cost${C.r} ${C.dim}(estimated from the price table; measured = reported by a gateway)${C.r}`);
877
+ const line = (k, u) => console.log(` ${pad(k, 12)} est=${u.cost != null ? usd(u.cost) : `${C.dim}n/a${C.r}`} measured=${u.costMeasured != null ? usd(u.costMeasured) : '—'}`);
878
+ line('Today', st.usage.today);
879
+ line('Week', st.usage.weekToDate);
880
+ line('Month', st.usage.monthToDate);
881
+ const f = st.forecast;
882
+ if (f?.monthEndCost !== null) {
883
+ console.log(` ${pad('Projection', 12)} month-end ≈ ${C.b}${usd(f.monthEndCost)}${C.r} ${C.dim}(${f.confidence} confidence)${C.r}`);
884
+ }
885
+ if (st.providersToday.length) {
886
+ console.log(`\n ${C.dim}today by provider:${C.r} ${st.providersToday.filter((p) => p.cost != null).map((p) => `${p.key} ${usd(p.cost)}`).join(' · ') || 'no priced usage'}`);
887
+ }
888
+ console.log('');
889
+ }
890
+
891
+ function pick2(u) {
892
+ return { tokens: u.tokens, requests: u.requests, cost: u.cost ?? null, costMeasured: u.costMeasured ?? null };
893
+ }
894
+
895
+ const CAP_BAR_W = 24;
896
+
897
+ function capacityBar(pctUsed) {
898
+ if (pctUsed == null) return `${C.dim}${'·'.repeat(CAP_BAR_W)}${C.r}`;
899
+ const filled = Math.min(CAP_BAR_W, Math.round(pctUsed * CAP_BAR_W));
900
+ const color = pctUsed >= 1 ? C.red : pctUsed >= 0.8 ? C.y : C.g;
901
+ return `${color}${'█'.repeat(filled)}${C.r}${C.dim}${'░'.repeat(CAP_BAR_W - filled)}${C.r}`;
902
+ }
903
+
904
+ /** `tokenflow capacity` — where each configured limit stands right now. */
905
+ async function cmdCapacity() {
906
+ const st = await liveStatus();
907
+ if (flags.json) return console.log(JSON.stringify(st.capacity, null, 2));
908
+ const states = st.capacity?.states || [];
909
+ if (!states.length) {
910
+ console.log(`\n No limits configured. TokenFlow never guesses vendor quotas — declare your own caps:`);
911
+ console.log(` ${C.dim}# ~/.tokenflow/config.yaml${C.r}`);
912
+ console.log(` ${C.dim}limits:${C.r}`);
913
+ console.log(` ${C.dim} - id: anthropic-month${C.r}`);
914
+ console.log(` ${C.dim} provider: anthropic # optional filter${C.r}`);
915
+ console.log(` ${C.dim} scope: month # day | week | month${C.r}`);
916
+ console.log(` ${C.dim} metric: tokens # tokens | input | output | requests | cost${C.r}`);
917
+ console.log(` ${C.dim} cap: 120000000${C.r}\n`);
918
+ return;
919
+ }
920
+ console.log(`\n ${C.b}Capacity${C.r} ${C.dim}${st.timezone} · resets are local-calendar${C.r}\n`);
921
+ for (const s of states) {
922
+ const glyph = s.status === 'exceeded' ? `${C.red}✗${C.r}` : s.status === 'warn' ? `${C.y}⚠${C.r}` : `${C.g}✓${C.r}`;
923
+ const scopeLabel = s.provider ? ` [${s.provider}]` : '';
924
+ console.log(` ${glyph} ${C.b}${s.label}${C.r} ${C.dim}(${s.scope}${scopeLabel})${C.r}`);
925
+ console.log(` ${capacityBar(s.pctUsed)} ${s.pctUsed != null ? `${Math.round(s.pctUsed * 100)}%` : '—'} of ${compact(s.cap)}`);
926
+ const bits = [`used ${compact(s.used)}`, `remaining ${compact(Math.max(0, s.remaining))}`];
927
+ if (s.etaHours !== null && s.status !== 'exceeded') {
928
+ bits.push(`projected exhaustion in ${humanDuration(s.etaHours * 3600000)}`);
929
+ }
930
+ if (s.resetsInMs > 0) bits.push(`resets in ${humanDuration(s.resetsInMs)}`);
931
+ console.log(` ${bits.join(' · ')}`);
932
+ }
933
+ if ((st.anomalies || []).some((a) => a.severity !== 'info')) {
934
+ console.log(`\n ${C.y}${(st.anomalies || []).filter((a) => a.severity !== 'info').length} active alert(s) — see 'tokenflow forecast --alerts' or the dashboard Live tab.${C.r}`);
935
+ }
936
+ console.log('');
937
+ }
938
+
939
+ /** `tokenflow forecast` — where usage is heading, with stated confidence. */
940
+ async function cmdForecast() {
941
+ const st = await liveStatus();
942
+ if (flags.json) {
943
+ return console.log(JSON.stringify({ forecast: st.forecast, anomalies: st.anomalies }, null, 2));
944
+ }
945
+ const f = st.forecast;
946
+ if (!f || f.tomorrow === null) {
947
+ return console.log(`\n ${C.y}Not enough history to forecast yet.${C.r} ${f?.reason || ''}\n`);
948
+ }
949
+ console.log(`\n ${C.b}Forecast${C.r} ${C.dim}linear trend over the last ${f.n} days · ${f.confidence} confidence${C.r}`);
950
+ console.log(` ${pad('Tomorrow', 14)} ≈ ${compact(f.tomorrow)} tokens`);
951
+ console.log(` ${pad('Next 7 days', 14)} ≈ ${compact(f.next7days)} tokens${f.next7daysCost != null ? ` · ${usd(f.next7daysCost)}` : ''}`);
952
+ if (f.monthEnd !== null) console.log(` ${pad('Month-end', 14)} ≈ ${compact(f.monthEnd)} tokens${f.monthEndCost !== null ? ` · ${usd(f.monthEndCost)}` : ''}`);
953
+ if (Array.isArray(f.tomorrowInterval)) {
954
+ console.log(` ${C.dim}tomorrow's likely range: ${compact(f.tomorrowInterval[0])} – ${compact(f.tomorrowInterval[1])}${C.r}`);
955
+ }
956
+ const alerts = (st.anomalies || []);
957
+ if (alerts.length) {
958
+ console.log(`\n ${C.b}Alerts${C.r}`);
959
+ for (const a of alerts.slice(0, 6)) {
960
+ const tag = a.severity === 'high' ? C.red : a.severity === 'warn' ? C.y : C.dim;
961
+ console.log(` ${tag}●${C.r} [${a.date}] ${wrap(a.detail, 88, 8)}`);
962
+ }
963
+ }
964
+ console.log(`\n ${C.dim}Projections are trends, not promises — they assume the recent pattern continues.${C.r}\n`);
965
+ }
966
+
967
+ /** `tokenflow digest` — build (and optionally deliver) the shareable summary. */
968
+ async function cmdDigest() {
969
+ const { run: runDigest } = await import('../src/commands/digest.js');
970
+ const f = {};
971
+ for (const [k, v] of Object.entries(flags)) if (v != null) f[k] = v;
972
+ const out = await runDigest({
973
+ from: f.from, to: f.to,
974
+ format: f.format === 'text' ? 'text' : 'markdown',
975
+ });
976
+
977
+ // Always persist to $TOKENFLOW_HOME/digests/ when delivering or asked to,
978
+ // so there is a local record even if every delivery channel fails.
979
+ if (f.deliver || f.save) {
980
+ const { paths } = await import('../src/core/config.js');
981
+ const dir = `${paths().root}/digests`;
982
+ fs.mkdirSync(dir, { recursive: true });
983
+ const file = `${dir}/${f.to || new Date().toISOString().slice(0, 10)}.md`;
984
+ fs.writeFileSync(file, out + '\n');
985
+ console.log(`${C.dim}saved ${file}${C.r}`);
986
+ }
987
+
988
+ if (f.deliver) {
989
+ const { loadConfig } = await import('../src/core/config.js');
990
+ const { deliverAll } = await import('../src/core/delivery.js');
991
+ const results = await deliverAll(loadConfig(), out, { subject: `TokenFlow digest ${f.to || ''}`.trim() });
992
+ for (const r of results) {
993
+ if (r.skipped) console.log(`${C.dim} ${r.channel}: not configured${C.r}`);
994
+ else if (r.ok) console.log(`${C.g}✓${C.r} delivered via ${r.channel}`);
995
+ else console.log(`${C.red}✗${C.r} ${r.channel}: ${r.error}`);
996
+ }
997
+ } else if (typeof f.out === 'string') {
998
+ fs.writeFileSync(f.out, out + '\n');
999
+ console.log(`${C.g}✓${C.r} wrote ${f.out}`);
1000
+ } else {
1001
+ console.log(out);
1002
+ }
1003
+ }
1004
+
1005
+ /** `tokenflow schedule` — install/remove the weekly digest LaunchAgent. */
1006
+ async function cmdSchedule() {
1007
+ const sched = await import('../src/core/schedule.js');
1008
+ if (flags.uninstall) { console.log(sched.uninstall()); return; }
1009
+ if (flags.status !== undefined && Object.prototype.hasOwnProperty.call(flags, 'status')) {
1010
+ const s = sched.status();
1011
+ console.log(`installed: ${s.installed ? 'yes' : 'no'} loaded: ${s.loaded ? 'yes' : 'no'}`);
1012
+ console.log(`latest digest: ${s.latestDigest || 'none yet'}`);
1013
+ return;
1014
+ }
1015
+ // --install (default when neither flag given? no — require explicit intent)
1016
+ console.log(sched.install({ when: flags.at }));
1017
+ }
1018
+
1019
+ /** `tokenflow budget` — monthly budget status + forecast alerts with dedup. */
1020
+ async function cmdBudget() {
1021
+ const cfg = loadConfig();
1022
+ const budget = { ...cfg.budget };
1023
+ if (flags.set) {
1024
+ const v = Number(flags.set);
1025
+ if (!(v > 0)) throw new Error('--set expects a positive number, e.g. budget --set 200');
1026
+ budget.monthly = v;
1027
+ saveConfig(merge(cfg, { budget }));
1028
+ console.log(`${C.g}✓${C.r} monthly budget set to $${v.toLocaleString('en-US')}`);
1029
+ }
1030
+ if (!budget.monthly) {
1031
+ console.log('No monthly budget configured. Set one:');
1032
+ console.log(` ${C.b}tokenflow budget --set 200${C.r} # $200/month, warn at 80% projected`);
1033
+ return;
1034
+ }
1035
+
1036
+ const { currentStatus } = await import('../src/core/live-status.js');
1037
+ const { computeBudgetState, shouldAlert } = await import('../src/core/budget.js');
1038
+ const { notify } = await import('../src/core/notify.js');
1039
+ const { status } = currentStatus();
1040
+ const today = new Date().toISOString().slice(0, 10);
1041
+
1042
+ const st = computeBudgetState(status, { monthly: budget.monthly, warnAtPct: budget.warnAtPct }, today);
1043
+ if (!st) { console.log('No usage data yet.'); return; }
1044
+
1045
+ const { fire } = shouldAlert(st, { force: !!flags.force });
1046
+
1047
+ console.log(`${C.b}Budget — ${today.slice(0, 7)}${C.r}`);
1048
+ console.log(` Monthly cap: $${budget.monthly.toLocaleString('en-US')} (warn at ${budget.warnAtPct ?? 80}%)`);
1049
+ if (st.spent != null) console.log(` Spent (est. MTD): $${st.spent.toLocaleString('en-US', { maximumFractionDigits: 2 })}`);
1050
+ if (st.projected != null) console.log(` Projected EOM: $${st.projected.toLocaleString('en-US', { maximumFractionDigits: 2 })} ${C.dim}(projection)${C.r}`);
1051
+
1052
+ const color = st.state === 'safe' ? C.g : st.state === 'unknown' ? C.dim : C.red;
1053
+ console.log(`\n State: ${color}${st.state.toUpperCase()}${C.r}`);
1054
+ if (st.message || st.reason) console.log(` ${(st.message || st.reason)}`);
1055
+
1056
+ if (fire && (budget.notify || flags.notify)) {
1057
+ try { notify({ title: `TokenFlow budget: ${st.state.replace(/_/g, ' ')}`, body: st.message || '' }); console.log(`${C.g}✓${C.r} OS notification sent`); }
1058
+ catch { /* notification is best-effort */ }
1059
+ if (cfg.delivery && Object.values(cfg.delivery).some((ch) => ch && Object.values(ch).some(Boolean))) {
1060
+ const { deliverAll } = await import('../src/core/delivery.js');
1061
+ const results = await deliverAll(cfg, `**TokenFlow budget alert** — ${st.state}\n\n${st.message || ''}`, { subject: `TokenFlow budget: ${st.state}` });
1062
+ for (const r of results) if (!r.skipped) console.log(r.ok ? `${C.g}✓${C.r} delivered via ${r.channel}` : `${C.red}✗${C.r} ${r.channel}: ${r.error}`);
1063
+ }
1064
+ } else if (st.state !== 'safe' && st.state !== 'unknown') {
1065
+ console.log(C.dim + ' (already alerted for this state this month — no spam)');
1066
+ }
1067
+ }
1068
+
1069
+ /** `tokenflow sync` — optional multi-machine aggregation via a shared folder. */
1070
+ async function cmdSync() {
1071
+ const { isEnabled, push, pull, machineId } = await import('../src/core/sync.js');
1072
+ const cfg = loadConfig();
1073
+
1074
+ if (flags.off) {
1075
+ saveConfig(merge(cfg, { sync: { ...cfg.sync, enabled: false } }));
1076
+ console.log(`${C.g}✓${C.r} sync disabled — nothing leaves this machine`);
1077
+ return;
1078
+ }
1079
+
1080
+ if (!isEnabled(cfg)) {
1081
+ console.log(`Multi-machine sync is ${C.b}OFF${C.r} by default. To enable it:
1082
+
1083
+ 1. Pick a folder that syncs between your machines
1084
+ (iCloud Drive, Dropbox, Syncthing mount…)
1085
+ 2. Add to ~/.tokenflow/config.yaml:
1086
+
1087
+ sync:
1088
+ enabled: true
1089
+ dir: ~/Sync/TokenFlow # that shared folder
1090
+ machineName: MacBook Pro # label shown in aggregated views
1091
+
1092
+ 3. Run ${C.b}tokenflow sync --push${C.r} on each machine.
1093
+
1094
+ What is shared: daily totals only (date, tokens, requests, est. cost).
1095
+ What is never shared: prompts, code, file paths, credentials.`);
1096
+ return;
1097
+ }
1098
+
1099
+ const id = machineId();
1100
+ if (flags.push || flags.pull === undefined) {
1101
+ // default action with no sub-flag = push + pull
1102
+ }
1103
+ try {
1104
+ if (!flags.pull) {
1105
+ const r = push({ config: cfg });
1106
+ console.log(r.days
1107
+ ? `${C.g}✓${C.r} pushed ${r.days} days → ${path.basename(r.file)}`
1108
+ : `${C.dim}nothing to push yet${C.r}`);
1109
+ }
1110
+ const merged = pull({ config: cfg });
1111
+ const totalReq = merged.days.reduce((a, d) => a + d.requests, 0);
1112
+ const totalCost = merged.days.reduce((a, d) => a + d.estCost, 0);
1113
+ console.log(`\n${C.b}Aggregated (${merged.machines.length} machine${merged.machines.length === 1 ? '' : 's'})${C.r}`);
1114
+ for (const d of merged.days.slice(-14)) {
1115
+ console.log(` ${d.date} ${d.machineCount} mach ${String(d.requests).padStart(6)} req`);
1116
+ }
1117
+ if (merged.days.length) {
1118
+ console.log(`\n Totals across all machines: ${totalReq.toLocaleString('en-US')} requests · $${totalCost.toFixed(2)} est.`);
1119
+ }
1120
+ console.log(C.dim + ` this machine's id: ${id}${C.r}`);
1121
+ } catch (e) {
1122
+ throw Object.assign(new Error(e.message), { exitCode: 1 });
1123
+ }
1124
+ }
1125
+
1126
+ /** `tokenflow models-compare` — cost/usage efficiency per model, own data. */
1127
+ async function cmdModelsCompare() {
1128
+ const { compare, renderText } = await import('../src/commands/models-compare.js');
1129
+ const f = {};
1130
+ for (const [k, v] of Object.entries(flags)) if (v != null) f[k] = v;
1131
+ const cmp = compare({ from: f.from, to: f.to });
1132
+ console.log(renderText(cmp));
1133
+ }
1134
+
1135
+ /** `tokenflow diagnostics` — local observability, nothing transmitted. */
1136
+ async function cmdDiagnostics() {
1137
+ const { collect, renderText } = await import('../src/commands/diagnostics.js');
1138
+ const d = collect({ includePaths: !!flags.paths });
1139
+ if (typeof flags.out === 'string') {
1140
+ fs.writeFileSync(flags.out, JSON.stringify(d, null, 2) + '\n');
1141
+ console.log(`${C.g}✓${C.r} wrote ${flags.out} — review it before sharing (paths included: ${!!flags.paths})`);
1142
+ } else if (flags.json) {
1143
+ console.log(JSON.stringify(d, null, 2));
1144
+ } else {
1145
+ console.log(renderText(d));
1146
+ }
1147
+ }
1148
+
1149
+ async function cmdMenubar() {
1150
+ const mode = String(flags.mode || loadConfig().ui?.menubarMode || 'auto');
1151
+
1152
+ // ---- the real thing: TokenFlow's own native menu bar app -----------------
1153
+ if (flags.app) {
1154
+ if (process.platform !== 'darwin') {
1155
+ throw Object.assign(new Error('the native menu bar app requires macOS'), {
1156
+ hint: 'on Linux/Windows use --swiftbar/--xbar/--out with a compatible bar.',
1157
+ });
1158
+ }
1159
+ const script = path.join(root(), 'scripts', 'build-menubar-app.sh');
1160
+ console.log(` building TokenFlow.app with swiftc…`);
1161
+ execFileSync('bash', [script], { stdio: 'inherit' });
1162
+ const src = path.join(root(), 'dist', 'TokenFlow.app');
1163
+ const dest = path.join(os.homedir(), 'Applications', 'TokenFlow.app');
1164
+ try { execFileSync('osascript', ['-e', 'quit app "TokenFlow"'], { stdio: 'ignore' }); } catch { /* not running */ }
1165
+ fs.rmSync(dest, { recursive: true, force: true });
1166
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
1167
+ fs.cpSync(src, dest, { recursive: true });
1168
+ execFileSync('open', [dest]);
1169
+ console.log(`${C.g}✓${C.r} installed: ${dest}`);
1170
+ console.log(` ${C.dim}Look for "TF" (or TF $… / a percentage) in your menu bar — click it for the full dropdown.${C.r}`);
1171
+ if (flags['login-item']) {
1172
+ try {
1173
+ execFileSync('osascript', ['-e',
1174
+ `tell application "System Events" to make login item at end with properties {path:"${dest}", hidden:false}`,
1175
+ ], { stdio: 'ignore' });
1176
+ console.log(` ${C.g}✓${C.r} added to Login Items (starts on every login)`);
1177
+ } catch {
1178
+ console.log(` ${C.y}!${C.r} could not add the login item automatically — add "${dest}" in System Settings › Login Items.`);
1179
+ }
1180
+ }
1181
+ return;
1182
+ }
1183
+
1184
+ // ---- cross-platform text-protocol fallback --------------------------------
1185
+ if (flags.render) {
1186
+ const st = await liveStatus();
1187
+ return console.log(renderXbar(st, { mode }));
1188
+ }
1189
+ if (flags.swiftbar || flags.xbar || typeof flags.out === 'string') {
1190
+ const home = os.homedir();
1191
+ const dir = typeof flags.out === 'string'
1192
+ ? flags.out
1193
+ : flags.xbar
1194
+ ? path.join(home, 'Library', 'Application Support', 'xbar', 'plugins')
1195
+ : path.join(home, 'Library', 'Plugins');
1196
+ const intervalMin = Math.max(1, Math.round(intervalSeconds() / 60));
1197
+ const file = installSwiftBarPlugin({ dir, mode, intervalMinutes: intervalMin });
1198
+ console.log(`${C.g}✓${C.r} plugin written: ${file}`);
1199
+ console.log(` ${C.dim}Point SwiftBar/xbar at "${dir}" (or restart it) and "tokenflow" appears in your menu bar.`);
1200
+ console.log(` Refresh cadence: every ${intervalMin} min via filename convention; display mode: ${mode}.${C.r}\n`);
1201
+ return;
1202
+ }
1203
+
1204
+ console.log(`
1205
+ ${C.b}Menu bar${C.r}
1206
+ usage: tokenflow menubar [--app | --render | --swiftbar | --xbar | --out <dir>]
1207
+ [--mode auto|tokens|cost|limit] [--login-item]
1208
+
1209
+ ${C.b}macOS — native app (recommended)${C.r}
1210
+ --app build TokenFlow.app with swiftc, install to ~/Applications
1211
+ and launch. Rich dropdown: usage, providers, capacity
1212
+ meters, forecast, alerts, refresh — no third-party app.
1213
+ --login-item also add it to Login Items
1214
+
1215
+ ${C.b}Other bars / platforms${C.r}
1216
+ --render print the xbar/SwiftBar-format text (used by the plugin)
1217
+ --swiftbar install the plugin script into ~/Library/Plugins
1218
+ --xbar install into the xbar plugin directory instead
1219
+ --out <dir> install into any compatible bar's plugin directory
1220
+ --mode what the bar shows: auto picks the most urgent signal${C.r}
1221
+ `);
1222
+ }
1223
+
1224
+ function intervalSeconds() {
1225
+ return Number(flags.interval) || loadConfig().watch?.intervalSeconds || 120;
1226
+ }
1227
+
1228
+ // ===================================================================== help ==
1229
+
1230
+ function help(code = 0) {
1231
+ console.log(`
1232
+ ${C.b}tokenflow${C.r} — local-first AI token usage & activity analytics
1233
+
1234
+ ${C.b}Getting started${C.r}
1235
+ tokenflow setup detect local AI tools and write config
1236
+ tokenflow up refresh + rebuild the offline file + open it
1237
+ tokenflow refresh ingest new usage (incremental, resumable)
1238
+ tokenflow dashboard open the local dashboard (no refresh)
1239
+ tokenflow demo explore with clearly-labelled synthetic data
1240
+ (--no-serve to generate the data and exit)
1241
+
1242
+ ${C.b}Live${C.r}
1243
+ tokenflow watch keep data + status current in the background
1244
+ tokenflow watch --once one cycle and exit (cron-friendly)
1245
+ tokenflow watch --status is a watcher running? how fresh is the data?
1246
+ tokenflow watch --stop stop a running watcher
1247
+ tokenflow usage today / week / month tokens & cost (--json)
1248
+ tokenflow cost estimated vs measured spend, projections
1249
+ tokenflow capacity configured limits: %, burn, reset countdowns
1250
+ tokenflow forecast trend projection + active alerts
1251
+ tokenflow menubar --swiftbar install a SwiftBar/xbar menu-bar plugin
1252
+ tokenflow status --bar the one-line menu-bar summary
1253
+
1254
+ ${C.b}Everyday${C.r}
1255
+ tokenflow status totals, coverage, data health, insights
1256
+ tokenflow providers what is detected / connected
1257
+ tokenflow provider add <id> enable an adapter
1258
+ tokenflow refresh --full re-ingest everything from scratch
1259
+ (refuses if a source's logs are unreachable;
1260
+ --force discards those records anyway)
1261
+ tokenflow refresh --budget 30 stop cleanly after 30s and resume next run
1262
+ tokenflow export --csv current view as CSV
1263
+ tokenflow export --csv --all every normalized record
1264
+ tokenflow export --html one self-contained offline dashboard file
1265
+
1266
+ ${C.b}Configure${C.r}
1267
+ tokenflow pricing show which models have a price, and from where
1268
+ tokenflow pricing --sources provenance of every built-in rate + tier multipliers
1269
+ tokenflow pricing --set "m=3,15,0.3,3.75"
1270
+ tokenflow import <file> CSV / JSON / JSONL / SQLite with field mapping
1271
+ tokenflow restore <file.csv> rebuild the store from a full export, re-priced
1272
+ tokenflow config show|path|export|import
1273
+
1274
+ ${C.b}Maintain${C.r}
1275
+ tokenflow doctor environment, paths, store, adapters
1276
+ tokenflow validate re-validate every stored record
1277
+ tokenflow compact drop superseded records after a rewrite
1278
+ tokenflow reset --yes delete ingested data (keeps config)
1279
+
1280
+ ${C.b}Flags${C.r} --json --quiet --provider <id> --from/--to <date> --port <n> --no-open --debug
1281
+
1282
+ ${C.dim}Everything runs locally. No usage data, prompt or file content ever leaves this machine.
1283
+ Data home: ${paths().root}${C.r}
1284
+ `);
1285
+ process.exitCode = code;
1286
+ }
1287
+
1288
+ // ===================================================================== util ==
1289
+
1290
+ function parseFlags(args) {
1291
+ const out = {};
1292
+ for (let i = 0; i < args.length; i++) {
1293
+ const a = args[i];
1294
+ if (!a.startsWith('--')) continue;
1295
+ if (a.startsWith('--no-')) { out[a.slice(5)] = false; continue; }
1296
+ const eq = a.indexOf('=');
1297
+ let key;
1298
+ let val;
1299
+ if (eq > -1) { key = a.slice(2, eq); val = a.slice(eq + 1); } else {
1300
+ key = a.slice(2);
1301
+ const next = args[i + 1];
1302
+ if (next && !next.startsWith('--')) { val = next; i++; } else val = true;
1303
+ }
1304
+ if (out[key] === undefined) out[key] = val;
1305
+ else out[key] = [].concat(out[key], val);
1306
+ }
1307
+ return out;
1308
+ }
1309
+
1310
+ function pad(s, n) {
1311
+ const t = String(s ?? '');
1312
+ return t.length >= n ? t : t + ' '.repeat(n - t.length);
1313
+ }
1314
+ function stripAnsi(s) {
1315
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '');
1316
+ }
1317
+ function bytes(n) {
1318
+ if (!n) return '0 B';
1319
+ const u = ['B', 'KB', 'MB', 'GB', 'TB'];
1320
+ let i = 0;
1321
+ let v = n;
1322
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
1323
+ return `${v.toFixed(i ? 1 : 0)} ${u[i]}`;
1324
+ }
1325
+ function rewrite(line) {
1326
+ if (!process.stdout.isTTY) { if (line) console.log(stripAnsi(line)); return; }
1327
+ process.stdout.write('\r\x1b[2K' + line);
1328
+ }
1329
+ function wrap(text, width, indent) {
1330
+ const words = String(text).split(' ');
1331
+ const pre = ' '.repeat(indent);
1332
+ let line = '';
1333
+ const lines = [];
1334
+ for (const w of words) {
1335
+ if ((line + ' ' + w).trim().length > width) { lines.push(line.trim()); line = w; } else line += ' ' + w;
1336
+ }
1337
+ if (line.trim()) lines.push(line.trim());
1338
+ return lines.join('\n' + pre);
1339
+ }
1340
+ function root() {
1341
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1342
+ }