tledger 0.1.4 → 0.2.1

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.
@@ -1,21 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from "node:child_process";
3
4
  import { existsSync, realpathSync } from "node:fs";
4
- import { createRequire } from "node:module";
5
5
  import {
6
+ mkdir,
6
7
  readFile,
7
8
  stat,
8
9
  } from "node:fs/promises";
9
10
  import { homedir } from "node:os";
10
- import { basename, resolve } from "node:path";
11
+ import { basename, dirname, resolve } from "node:path";
11
12
  import { fileURLToPath } from "node:url";
12
13
 
13
- import { renderTerminal } from "./token-ledger-terminal.mjs";
14
+ import {
15
+ MODEL_COLORS as TERMINAL_MODEL_COLORS,
16
+ renderTerminal,
17
+ } from "./token-ledger-terminal.mjs";
18
+ import { buildUsageTrend, multiDayBounds } from "./token-ledger-trend.mjs";
19
+ import {
20
+ renderTrendImage,
21
+ writeTrendPng,
22
+ } from "./token-ledger-trend-image.mjs";
23
+ import { renderTrendCombo } from "./token-ledger-trend-terminal.mjs";
14
24
  import { startInteractive } from "./token-ledger-tui.mjs";
15
- import { modelDisplayName } from "../lib/token-ledger-models.mjs";
16
-
17
- const require = createRequire(import.meta.url);
18
- export const VERSION = require("../package.json").version;
19
25
 
20
26
  export const DEFAULT_SNAPSHOT = resolve(
21
27
  homedir(),
@@ -23,29 +29,34 @@ export const DEFAULT_SNAPSHOT = resolve(
23
29
  "token-ledger-snapshot.json",
24
30
  );
25
31
  const DEFAULT_TOP = 10;
26
- const MAX_RANGE_DAYS = 100_000;
27
32
  const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+ export const SNAPSHOT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
34
+ export const ROLLING_24_HOURS_MS = 24 * 60 * 60 * 1000;
35
+ const ANSI_RESET = "\u001b[0m";
36
+ const MODEL_COLORS = {
37
+ sol: TERMINAL_MODEL_COLORS.sol,
38
+ luna: TERMINAL_MODEL_COLORS.luna,
39
+ terra: TERMINAL_MODEL_COLORS.terra,
40
+ "gpt-5.5": TERMINAL_MODEL_COLORS.gpt,
41
+ "gpt-5.4": TERMINAL_MODEL_COLORS.gpt,
42
+ other: TERMINAL_MODEL_COLORS.other,
43
+ };
28
44
 
29
45
  function usage() {
30
46
  return `Token Ledger terminal usage
31
47
 
32
48
  Usage:
33
- tledger
49
+ tledger 1d Rolling 24-hour project breakdown (ends now)
50
+ tledger day <YYYY-MM-DD>
34
51
  tledger week [end-day]
35
- tledger day [day]
36
- tledger month [end-day]
37
- tledger <number>d [end-day]
38
- tledger all
39
-
40
- Ranges:
41
- day One calendar day
42
- week 7 days ending on end-day (default: today)
43
- month 30 days ending on end-day (default: today)
44
- <number>d That many days ending on end-day, for example 90d
45
- all Every dated event in the snapshot
52
+ tledger trend [7d|14d|30d]
53
+ tledger report [7d|14d|30d]
54
+ npm run usage:day -- <YYYY-MM-DD>
55
+ npm run usage:week -- [end-day]
46
56
 
47
57
  Options:
48
58
  --date <day> Date as YYYY-MM-DD, today, or yesterday
59
+ --period <window> Trend window: 7d, 14d, or 30d
49
60
  --input <file> Snapshot to read (default: ~/.token-ledger/token-ledger-snapshot.json)
50
61
  --refresh Rebuild the default snapshot from CODEX_HOME or ~/.codex
51
62
  --no-refresh Use the cached snapshot without checking local JSONL files
@@ -54,16 +65,23 @@ Options:
54
65
  --top <number> Number of projects to show (default: 10)
55
66
  --width <number> Terminal layout width in columns
56
67
  --raw-projects Keep singleton thread labels instead of grouping them
57
- -anon Replace project names with Project 1, Project 2, and so on
58
68
  --no-archived Skip archived_sessions when refreshing
59
69
  --plain Disable terminal colors
60
70
  --ascii Use ASCII bars instead of Unicode blocks
61
71
  --static Print once instead of opening the interactive dashboard
62
- -v, --version Show the installed version
72
+ --drain Trend columns show observed limit drain percent instead of token volume
73
+ --image Write trend view as a PNG image
74
+ --image-output <file> PNG output path for trend view
75
+ --image-width <px> PNG image width from 900 to 2400 pixels
76
+ --youplot Use the legacy single-series YouPlot renderer
63
77
  --help Show this help
64
78
 
65
- The default view is the seven-day window ending today. Token Ledger never
66
- uploads data or renders message bodies, tool payloads, or credentials.`;
79
+ The report command writes the dashboard PNG (same as trend --image) to
80
+ token-ledger-report-<period>.png; use --image-output to choose the path.
81
+
82
+ The command reads a privacy-reduced Token Ledger snapshot. It never uploads
83
+ the snapshot or prints message bodies, tool payloads, credentials, or local
84
+ input/source paths. Explicit PNG output paths are reported after writing.`;
67
85
  }
68
86
 
69
87
  function readOption(argv, index, name) {
@@ -74,38 +92,21 @@ function readOption(argv, index, name) {
74
92
  return value;
75
93
  }
76
94
 
77
- function rangeSpec(value) {
78
- if (value === "day") return { range: "day", rangeDays: 1 };
79
- if (value === "week") return { range: "week", rangeDays: 7 };
80
- if (value === "month") return { range: "month", rangeDays: 30 };
81
- if (value === "all") return { range: "all", rangeDays: null };
82
- const custom = /^(\d+)d$/.exec(value ?? "");
83
- if (!custom) return null;
84
- const rangeDays = Number(custom[1]);
85
- if (
86
- !Number.isSafeInteger(rangeDays) ||
87
- rangeDays < 1 ||
88
- rangeDays > MAX_RANGE_DAYS
89
- ) {
90
- throw new Error(
91
- `Day range must be an integer from 1 to ${MAX_RANGE_DAYS.toLocaleString("en-US")}, for example 90d.`,
92
- );
93
- }
94
- return { range: `${rangeDays}d`, rangeDays };
95
- }
96
-
97
95
  export function parseArgs(argv) {
98
- const requestedRange = rangeSpec(argv[0]);
99
- const commandExplicit = Boolean(requestedRange);
100
- if (argv[0] && !argv[0].startsWith("-") && !commandExplicit) {
101
- throw new Error(
102
- `Unknown command: ${argv[0]}. Use day, week, month, all, or a duration like 90d.`,
103
- );
104
- }
105
- const command = requestedRange ?? { range: "week", rangeDays: 7 };
96
+ const rolling24hCommand = argv[0] === "1d";
97
+ const command = rolling24hCommand
98
+ ? "rolling24h"
99
+ : argv[0] === "week"
100
+ ? "week"
101
+ : argv[0] === "trend" || argv[0] === "report"
102
+ ? "trend"
103
+ : "day";
106
104
  const options = {
107
- range: command.range,
108
- rangeDays: command.rangeDays,
105
+ range: command,
106
+ view: command === "trend" ? "trend" : "projects",
107
+ rolling24h: rolling24hCommand,
108
+ report: argv[0] === "report",
109
+ trendDays: 7,
109
110
  date: null,
110
111
  input: DEFAULT_SNAPSHOT,
111
112
  inputExplicit: false,
@@ -117,24 +118,40 @@ export function parseArgs(argv) {
117
118
  top: DEFAULT_TOP,
118
119
  width: null,
119
120
  rawProjects: false,
120
- anonymizeProjects: false,
121
121
  plain: false,
122
122
  ascii: false,
123
123
  static: false,
124
- version: false,
124
+ image: false,
125
+ imageOutput: null,
126
+ imageWidth: null,
127
+ drain: false,
128
+ legacyPlot: false,
125
129
  help: false,
126
130
  };
127
131
 
128
- let index = commandExplicit ? 1 : 0;
132
+ let trendPeriodSeen = false;
133
+ let index = ["1d", "day", "week", "trend", "report"].includes(argv[0]) ? 1 : 0;
129
134
  for (; index < argv.length; index += 1) {
130
135
  const argument = argv[index];
131
136
  if (argument === "--help" || argument === "-h") {
132
137
  options.help = true;
133
- } else if (argument === "--version" || argument === "-v") {
134
- options.version = true;
135
138
  } else if (argument === "--date") {
136
139
  options.date = readOption(argv, index, "--date");
137
140
  index += 1;
141
+ } else if (argument === "--period") {
142
+ if (options.view !== "trend") {
143
+ throw new Error("--period is only available for the trend view.");
144
+ }
145
+ if (trendPeriodSeen) {
146
+ throw new Error("Trend period can only be specified once.");
147
+ }
148
+ const value = readOption(argv, index, "--period");
149
+ if (!["7d", "14d", "30d"].includes(value)) {
150
+ throw new Error("Trend period must be 7d, 14d, or 30d.");
151
+ }
152
+ options.trendDays = Number.parseInt(value, 10);
153
+ trendPeriodSeen = true;
154
+ index += 1;
138
155
  } else if (argument === "--input") {
139
156
  options.input = resolve(readOption(argv, index, "--input"));
140
157
  options.inputExplicit = true;
@@ -165,8 +182,6 @@ export function parseArgs(argv) {
165
182
  index += 1;
166
183
  } else if (argument === "--raw-projects") {
167
184
  options.rawProjects = true;
168
- } else if (argument === "-anon") {
169
- options.anonymizeProjects = true;
170
185
  } else if (argument === "--no-archived") {
171
186
  options.includeArchived = false;
172
187
  } else if (argument === "--plain") {
@@ -175,25 +190,88 @@ export function parseArgs(argv) {
175
190
  options.ascii = true;
176
191
  } else if (argument === "--static") {
177
192
  options.static = true;
178
- } else if (commandExplicit && !argument.startsWith("-") && !options.date) {
193
+ } else if (argument === "--drain") {
194
+ if (options.view !== "trend") {
195
+ throw new Error("--drain is only available for the trend view.");
196
+ }
197
+ options.drain = true;
198
+ } else if (argument === "--image") {
199
+ if (options.view !== "trend") {
200
+ throw new Error("--image is only available for the trend view.");
201
+ }
202
+ options.image = true;
203
+ } else if (argument === "--image-output") {
204
+ if (options.view !== "trend") {
205
+ throw new Error("--image-output is only available for the trend view.");
206
+ }
207
+ const value = readOption(argv, index, "--image-output");
208
+ if (!value.toLowerCase().endsWith(".png")) {
209
+ throw new Error("--image-output must end in .png.");
210
+ }
211
+ options.image = true;
212
+ options.imageOutput = resolve(value);
213
+ index += 1;
214
+ } else if (argument === "--image-width") {
215
+ if (options.view !== "trend") {
216
+ throw new Error("--image-width is only available for the trend view.");
217
+ }
218
+ const value = Number(readOption(argv, index, "--image-width"));
219
+ if (!Number.isInteger(value) || value < 900 || value > 2400) {
220
+ throw new Error("--image-width must be an integer from 900 to 2400.");
221
+ }
222
+ options.image = true;
223
+ options.imageWidth = value;
224
+ index += 1;
225
+ } else if (argument === "--youplot") {
226
+ options.legacyPlot = true;
227
+ } else if (
228
+ !argument.startsWith("-") &&
229
+ options.view === "projects" &&
230
+ argument === "1d" &&
231
+ !options.date
232
+ ) {
233
+ if (argv[0] !== "day") {
234
+ throw new Error(
235
+ "The 1d alias is only available as `tledger 1d` or `tledger day 1d`.",
236
+ );
237
+ }
238
+ options.range = "rolling24h";
239
+ options.rolling24h = true;
240
+ } else if (!argument.startsWith("-") && options.view === "trend") {
241
+ if (trendPeriodSeen) {
242
+ throw new Error("Trend period can only be specified once.");
243
+ }
244
+ if (!["7d", "14d", "30d"].includes(argument)) {
245
+ throw new Error("Trend period must be 7d, 14d, or 30d.");
246
+ }
247
+ options.trendDays = Number.parseInt(argument, 10);
248
+ trendPeriodSeen = true;
249
+ } else if (!argument.startsWith("-") && !options.date) {
179
250
  options.date = argument;
180
251
  } else {
181
252
  throw new Error(`Unknown option: ${argument}`);
182
253
  }
183
254
  }
184
255
 
185
- if (!options.help && !options.version && options.range === "all" && options.date) {
186
- throw new Error("The all range does not accept an end day.");
256
+ if (options.report) options.image = true;
257
+ if (!options.help && options.rolling24h && options.date) {
258
+ throw new Error("1d does not accept --date; its rolling window ends now.");
187
259
  }
188
- if (!options.help && !options.version && !options.date && options.range !== "all") {
260
+ if (!options.help && !options.date && (options.range === "week" || options.view === "trend")) {
189
261
  options.date = "today";
190
262
  }
191
- if (!options.help && !options.version && options.refresh && !options.autoRefresh) {
263
+ if (!options.help && !options.date && !options.rolling24h) {
264
+ throw new Error("A day is required, for example: tledger day 2026-08-01");
265
+ }
266
+ if (!options.help && options.refresh && !options.autoRefresh) {
192
267
  throw new Error("--refresh cannot be combined with --no-refresh.");
193
268
  }
194
- if (!options.help && !options.version && options.refresh && options.inputExplicit) {
269
+ if (!options.help && options.refresh && options.inputExplicit) {
195
270
  throw new Error("--refresh cannot be combined with --input.");
196
271
  }
272
+ if (!options.help && options.view === "trend" && options.legacyPlot) {
273
+ throw new Error("--youplot is only available for the project view.");
274
+ }
197
275
  return options;
198
276
  }
199
277
 
@@ -294,99 +372,35 @@ export function dayBounds(value, timeZone) {
294
372
  const nextDateString = shiftCalendarDate(dateString, 1);
295
373
  const start = zonedMidnight(dateString, timeZone);
296
374
  const end = zonedMidnight(nextDateString, timeZone);
297
- return {
298
- dateString,
299
- startDateString: dateString,
300
- endDateString: dateString,
301
- start,
302
- end,
303
- timeZone,
304
- rangeDays: 1,
305
- };
375
+ return { dateString, start, end, timeZone };
306
376
  }
307
377
 
308
- export function rollingBounds(value, timeZone, rangeDays) {
309
- if (
310
- !Number.isSafeInteger(rangeDays) ||
311
- rangeDays < 1 ||
312
- rangeDays > MAX_RANGE_DAYS
313
- ) {
314
- throw new Error(
315
- `Range days must be an integer from 1 to ${MAX_RANGE_DAYS.toLocaleString("en-US")}.`,
316
- );
317
- }
378
+ export function weekBounds(value, timeZone) {
318
379
  const endDay = dayBounds(value, timeZone);
319
- const startDateString = shiftCalendarDate(endDay.dateString, -(rangeDays - 1));
380
+ const startDateString = shiftCalendarDate(endDay.dateString, -6);
320
381
  return {
321
382
  ...endDay,
322
383
  startDateString,
323
384
  endDateString: endDay.dateString,
324
385
  start: zonedMidnight(startDateString, timeZone),
325
- rangeDays,
386
+ rangeDays: 7,
326
387
  };
327
388
  }
328
389
 
329
- export function weekBounds(value, timeZone) {
330
- return rollingBounds(value, timeZone, 7);
331
- }
332
-
333
- export function monthBounds(value, timeZone) {
334
- return rollingBounds(value, timeZone, 30);
335
- }
336
-
337
- function eventTimestamp(event) {
338
- if (typeof event?.timestamp !== "string" || !event.timestamp.trim()) {
339
- return Number.NaN;
340
- }
341
- return new Date(event.timestamp).getTime();
342
- }
343
-
344
- export function allBounds(snapshot, timeZone) {
390
+ export function rolling24hBounds(value = new Date(), timeZone = DEFAULT_TIME_ZONE) {
345
391
  validateTimeZone(timeZone);
346
- let earliest = Number.POSITIVE_INFINITY;
347
- let latest = Number.NEGATIVE_INFINITY;
348
- for (const event of snapshot.events ?? []) {
349
- const timestamp = eventTimestamp(event);
350
- if (!Number.isFinite(timestamp)) continue;
351
- earliest = Math.min(earliest, timestamp);
352
- latest = Math.max(latest, timestamp);
353
- }
354
- if (!Number.isFinite(earliest)) {
355
- return {
356
- ...dayBounds("today", timeZone),
357
- rangeDays: null,
358
- allTime: true,
359
- };
392
+ const end = value instanceof Date ? new Date(value.getTime()) : new Date(value);
393
+ if (!Number.isFinite(end.getTime())) {
394
+ throw new Error("Rolling 24-hour window requires a valid end time.");
360
395
  }
361
- const dateString = (timestamp) => dateStringFromParts(numericDateParts({
362
- value: new Date(timestamp),
363
- timeZone,
364
- }));
365
- const startDateString = dateString(earliest);
366
- const endDateString = dateString(latest);
367
396
  return {
368
- dateString: endDateString,
369
- startDateString,
370
- endDateString,
371
- start: zonedMidnight(startDateString, timeZone),
372
- end: zonedMidnight(shiftCalendarDate(endDateString, 1), timeZone),
397
+ start: new Date(end.getTime() - ROLLING_24_HOURS_MS),
398
+ end,
373
399
  timeZone,
374
- rangeDays: null,
375
- allTime: true,
400
+ rangeHours: 24,
376
401
  };
377
402
  }
378
403
 
379
- function boundsForOptions(options, snapshot) {
380
- if (options.range === "all") return allBounds(snapshot, options.timeZone);
381
- return rollingBounds(options.date, options.timeZone, options.rangeDays);
382
- }
383
-
384
- function describeRange(options, bounds) {
385
- if (options.range === "all") return "all time";
386
- if (bounds.rangeDays === 1) return bounds.dateString;
387
- return `${bounds.startDateString} through ${bounds.endDateString}`;
388
- }
389
-
390
404
  export function sanitizeTerminalText(value) {
391
405
  return String(value ?? "")
392
406
  .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
@@ -401,6 +415,62 @@ function cleanLabel(value, fallback) {
401
415
  return label || fallback;
402
416
  }
403
417
 
418
+ const QUOTED_ABSOLUTE_PATH =
419
+ /(["'])(\/(?!\/)[^"'\r\n]*|(?:\/\/|\\\\)[^"'\r\n]+|[A-Za-z]:[\\/][^"'\r\n]*)\1/g;
420
+ const UNQUOTED_ABSOLUTE_PATH =
421
+ /(^|[\s([{=])((?:\/(?!\/)|\/\/|\\\\|[A-Za-z]:[\\/])[^\s"'`)\]},;]+)/g;
422
+
423
+ function isAbsoluteLocalPath(path) {
424
+ return (
425
+ path.startsWith("/") ||
426
+ path.startsWith("\\\\") ||
427
+ /^[A-Za-z]:[\\/]/.test(path)
428
+ );
429
+ }
430
+
431
+ export function safeDisplayLabel(value, fallback = "local path") {
432
+ const normalized = String(value ?? "").replaceAll("\\", "/");
433
+ const label = sanitizeTerminalText(basename(normalized))
434
+ .replace(/\s+/g, " ")
435
+ .trim();
436
+ return label && label !== "." && label !== ".." ? label : fallback;
437
+ }
438
+
439
+ export function redactLocalPaths(value, paths = []) {
440
+ let redacted = String(value ?? "");
441
+ const explicitPaths = new Set(
442
+ paths
443
+ .filter(Boolean)
444
+ .map((path) => String(path))
445
+ .filter(isAbsoluteLocalPath),
446
+ );
447
+ const pathsToRedact = [...new Set([
448
+ ...explicitPaths,
449
+ homedir(),
450
+ process.cwd(),
451
+ ])]
452
+ .filter((path) => path && path !== "/")
453
+ .sort((left, right) => right.length - left.length);
454
+
455
+ for (const path of pathsToRedact) {
456
+ redacted = redacted.replaceAll(
457
+ path,
458
+ explicitPaths.has(path) ? safeDisplayLabel(path) : "[local path]",
459
+ );
460
+ }
461
+
462
+ return redacted
463
+ .replace(QUOTED_ABSOLUTE_PATH, (_match, quote) => `${quote}[local path]${quote}`)
464
+ .replace(UNQUOTED_ABSOLUTE_PATH, (_match, prefix) => `${prefix}[local path]`);
465
+ }
466
+
467
+ function safeErrorMessage(error, paths = []) {
468
+ return redactLocalPaths(
469
+ error instanceof Error ? error.message : String(error),
470
+ paths,
471
+ );
472
+ }
473
+
404
474
  function displayLabel(value) {
405
475
  const label = cleanLabel(value, "Unlabelled activity");
406
476
  if (label.length <= 30) return label;
@@ -427,14 +497,20 @@ export function oneOffProjects(snapshot) {
427
497
 
428
498
  function modelLabel(value) {
429
499
  const model = cleanLabel(value, "Unknown model");
430
- return modelDisplayName(model);
500
+ const lower = model.toLowerCase();
501
+ if (lower.includes("sol")) return "Sol";
502
+ if (lower.includes("luna")) return "Luna";
503
+ if (lower.includes("terra")) return "Terra";
504
+ if (lower === "gpt-5.5") return "GPT-5.5";
505
+ if (lower === "gpt-5.4") return "GPT-5.4";
506
+ return model;
431
507
  }
432
508
 
433
509
  export function filterDayEvents(snapshot, bounds) {
434
510
  const start = bounds.start.getTime();
435
511
  const end = bounds.end.getTime();
436
512
  return (snapshot.events ?? []).filter((event) => {
437
- const timestamp = eventTimestamp(event);
513
+ const timestamp = new Date(event.timestamp).getTime();
438
514
  return Number.isFinite(timestamp) && timestamp >= start && timestamp < end;
439
515
  });
440
516
  }
@@ -459,6 +535,8 @@ export function aggregateProjects(snapshot, events, options = {}) {
459
535
  toolCalls: 0,
460
536
  events: 0,
461
537
  threadIds: new Set(),
538
+ rateCardCredits: 0,
539
+ knownCreditTokens: 0,
462
540
  models: new Map(),
463
541
  };
464
542
  row.totalTokens += Number(event.totalTokens) || 0;
@@ -467,14 +545,23 @@ export function aggregateProjects(snapshot, events, options = {}) {
467
545
  row.toolCalls += Number(event.toolCalls) || 0;
468
546
  row.events += 1;
469
547
  if (event.threadId) row.threadIds.add(event.threadId);
548
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
549
+ row.rateCardCredits += Number(event.rateCardCredits);
550
+ row.knownCreditTokens += Number(event.totalTokens) || 0;
551
+ }
552
+
470
553
  const model = modelLabel(event.model);
471
554
  const modelRow = row.models.get(model) ?? {
472
555
  model,
473
556
  totalTokens: 0,
474
557
  events: 0,
558
+ rateCardCredits: 0,
475
559
  };
476
560
  modelRow.totalTokens += Number(event.totalTokens) || 0;
477
561
  modelRow.events += 1;
562
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
563
+ modelRow.rateCardCredits += Number(event.rateCardCredits);
564
+ }
478
565
  row.models.set(model, modelRow);
479
566
  grouped.set(project, row);
480
567
  }
@@ -492,13 +579,93 @@ export function aggregateProjects(snapshot, events, options = {}) {
492
579
  return right.totalTokens - left.totalTokens;
493
580
  }
494
581
  return left.project.localeCompare(right.project);
582
+ });
583
+ }
584
+
585
+ function totalSummary(events) {
586
+ return events.reduce(
587
+ (summary, event) => {
588
+ summary.totalTokens += Number(event.totalTokens) || 0;
589
+ summary.outputTokens += Number(event.outputTokens) || 0;
590
+ summary.toolCalls += Number(event.toolCalls) || 0;
591
+ if (event.threadId) summary.threadIds.add(event.threadId);
592
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
593
+ summary.rateCardCredits += Number(event.rateCardCredits);
594
+ summary.knownCreditTokens += Number(event.totalTokens) || 0;
595
+ }
596
+ return summary;
597
+ },
598
+ {
599
+ totalTokens: 0,
600
+ outputTokens: 0,
601
+ toolCalls: 0,
602
+ rateCardCredits: 0,
603
+ knownCreditTokens: 0,
604
+ threadIds: new Set(),
605
+ },
606
+ );
607
+ }
608
+
609
+ function compact(value, digits = 2) {
610
+ if (!Number.isFinite(value)) return "—";
611
+ const absolute = Math.abs(value);
612
+ const units = [
613
+ [1_000_000_000, "B"],
614
+ [1_000_000, "M"],
615
+ [1_000, "K"],
616
+ ];
617
+ for (const [divisor, suffix] of units) {
618
+ if (absolute >= divisor) {
619
+ const scaled = value / divisor;
620
+ const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : digits;
621
+ return `${scaled.toFixed(precision)}${suffix}`;
622
+ }
623
+ }
624
+ return Math.round(value).toLocaleString("en-US");
625
+ }
626
+
627
+ function percent(value) {
628
+ return `${value.toFixed(value >= 10 ? 1 : 2)}%`;
629
+ }
630
+
631
+ function chartUnit(maximum) {
632
+ if (maximum >= 1_000_000_000) return { divisor: 1_000_000_000, suffix: "B" };
633
+ if (maximum >= 1_000_000) return { divisor: 1_000_000, suffix: "M" };
634
+ if (maximum >= 1_000) return { divisor: 1_000, suffix: "K" };
635
+ return { divisor: 1, suffix: "tokens" };
636
+ }
637
+
638
+ function chartNumber(value, divisor) {
639
+ const scaled = value / divisor;
640
+ if (scaled >= 100) return scaled.toFixed(0);
641
+ if (scaled >= 10) return scaled.toFixed(1);
642
+ return scaled.toFixed(2);
643
+ }
644
+
645
+ function colorize(value, code, enabled) {
646
+ const codes = Array.isArray(code) ? code.join(";") : code;
647
+ return enabled ? `\u001b[${codes}m${value}${ANSI_RESET}` : value;
648
+ }
649
+
650
+ function modelColor(model) {
651
+ const lower = model.toLowerCase();
652
+ if (lower.includes("sol")) return MODEL_COLORS.sol;
653
+ if (lower.includes("luna")) return MODEL_COLORS.luna;
654
+ if (lower.includes("terra")) return MODEL_COLORS.terra;
655
+ if (lower.includes("gpt-5.5") || lower.includes("gpt-5.4")) {
656
+ return MODEL_COLORS["gpt-5.5"];
657
+ }
658
+ return MODEL_COLORS.other;
659
+ }
660
+
661
+ function modelMix(row, enabled) {
662
+ return row.models
663
+ .slice(0, 4)
664
+ .map((model) => {
665
+ const share = row.totalTokens > 0 ? (model.totalTokens / row.totalTokens) * 100 : 0;
666
+ return `${colorize(model.model, modelColor(model.model), enabled)} ${percent(share)}`;
495
667
  })
496
- .map((row, index) => ({
497
- ...row,
498
- displayProject: options.anonymizeProjects
499
- ? `Project ${index + 1}`
500
- : row.displayProject,
501
- }));
668
+ .join(" · ");
502
669
  }
503
670
 
504
671
  function sourceLabel(snapshotPath, snapshot) {
@@ -508,64 +675,65 @@ function sourceLabel(snapshotPath, snapshot) {
508
675
  timeStyle: "short",
509
676
  })
510
677
  : "unknown time";
511
- return `${cleanLabel(basename(snapshotPath), "snapshot")} · captured ${generated}`;
512
- }
513
-
514
- function latestActivityDateString(snapshot, timeZone) {
515
- let latestTimestamp = Number.NEGATIVE_INFINITY;
516
- for (const event of snapshot.events ?? []) {
517
- const timestamp = eventTimestamp(event);
518
- if (Number.isFinite(timestamp) && timestamp > latestTimestamp) {
519
- latestTimestamp = timestamp;
520
- }
521
- }
522
- if (!Number.isFinite(latestTimestamp)) return null;
523
- return dateStringFromParts(numericDateParts({
524
- value: new Date(latestTimestamp),
525
- timeZone,
526
- }));
678
+ return `${safeDisplayLabel(snapshotPath, "snapshot")} · captured ${generated}`;
527
679
  }
528
680
 
529
- function displayCalendarDate(dateString) {
530
- const [year, month, day] = dateString.split("-").map(Number);
531
- return new Intl.DateTimeFormat("en-US", {
532
- month: "long",
533
- day: "numeric",
534
- year: "numeric",
535
- timeZone: "UTC",
536
- }).format(new Date(Date.UTC(year, month - 1, day)));
537
- }
538
-
539
- function emptyState(options, snapshot, bounds) {
540
- const lines = [
541
- `No model-call events found for ${describeRange(options, bounds)} (${bounds.timeZone}).`,
542
- "Token Ledger reads only Codex history stored on this computer.",
681
+ function runYouPlot(rows, options, dateLabel, unit) {
682
+ const chartInput = [
683
+ "project\tvalue",
684
+ ...rows.map(
685
+ (row) => `${row.displayProject.replace(/[\t\r\n]+/g, " ")}\t${chartNumber(row.totalTokens, unit.divisor)}`,
686
+ ),
687
+ ].join("\n");
688
+ const terminalWidth = Number(process.stdout.columns) || 100;
689
+ const width = options.width ?? Math.max(56, Math.min(110, terminalWidth - 4));
690
+ const useColor = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
691
+ const args = [
692
+ "bar",
693
+ "-H",
694
+ "-o",
695
+ "-",
696
+ "-t",
697
+ `Top ${rows.length} projects · tokens (${unit.suffix}) · ${dateLabel}`,
698
+ "-w",
699
+ String(width),
700
+ "--symbol",
701
+ options.ascii ? "#" : "█",
543
702
  ];
544
- const latestDate = latestActivityDateString(snapshot, bounds.timeZone);
545
- if (latestDate) {
546
- lines.push(`Latest local activity: ${displayCalendarDate(latestDate)}.`);
547
- lines.push(`Try: tledger ${options.range} ${latestDate}`);
703
+ if (useColor) args.push("-C", "-c", "blue");
704
+ else args.push("-M");
705
+
706
+ const result = spawnSync("uplot", args, {
707
+ input: `${chartInput}\n`,
708
+ encoding: "utf8",
709
+ maxBuffer: 1_000_000,
710
+ });
711
+ if (result.error?.code === "ENOENT") {
712
+ throw new Error(
713
+ "YouPlot is required. Install it with `brew install youplot`, then rerun this command.",
714
+ );
715
+ }
716
+ if (result.status !== 0) {
717
+ throw new Error(result.stderr?.trim() || "YouPlot failed to render the chart.");
548
718
  }
549
- lines.push(`Source: ${sourceLabel(options.input, snapshot)}`);
550
- return lines.join("\n");
719
+ return result.stdout;
551
720
  }
552
721
 
553
722
  async function readSnapshot(snapshotPath) {
723
+ const snapshotLabel = safeDisplayLabel(snapshotPath, "snapshot");
554
724
  let parsed;
555
725
  try {
556
726
  parsed = JSON.parse(await readFile(snapshotPath, "utf8"));
557
727
  } catch (error) {
558
728
  if (error?.code === "ENOENT") {
559
- throw new Error(`Snapshot not found: ${sanitizeTerminalText(snapshotPath)}`);
729
+ throw new Error(`Snapshot not found: ${snapshotLabel}`);
560
730
  }
561
731
  throw new Error(
562
- `Could not read snapshot ${sanitizeTerminalText(snapshotPath)}: ${sanitizeTerminalText(error.message)}`,
732
+ `Could not read snapshot ${snapshotLabel}: ${safeErrorMessage(error, [snapshotPath])}`,
563
733
  );
564
734
  }
565
735
  if (!parsed || !Array.isArray(parsed.events)) {
566
- throw new Error(
567
- `Snapshot is missing its events array: ${sanitizeTerminalText(snapshotPath)}`,
568
- );
736
+ throw new Error(`Snapshot is missing its events array: ${snapshotLabel}`);
569
737
  }
570
738
  return parsed;
571
739
  }
@@ -573,51 +741,89 @@ async function readSnapshot(snapshotPath) {
573
741
  async function refreshSnapshot(options) {
574
742
  if (!existsSync(options.codexHome)) {
575
743
  throw new Error(
576
- `Codex data directory not found: ${sanitizeTerminalText(options.codexHome)}`,
744
+ `Codex data directory not found: ${safeDisplayLabel(options.codexHome, "Codex data directory")}`,
745
+ );
746
+ }
747
+ let progressStarted = false;
748
+ try {
749
+ const { collectUsage, writePrivateSnapshot } = await import(
750
+ "../lib/token-ledger-importer.mjs"
751
+ );
752
+ process.stderr.write("Token Ledger: refreshing local snapshot…\n");
753
+ progressStarted = true;
754
+ const snapshot = await collectUsage(
755
+ {
756
+ output: options.input,
757
+ codexHome: options.codexHome,
758
+ includeArchived: options.includeArchived,
759
+ since: null,
760
+ },
761
+ ({ current, total }) => {
762
+ process.stderr.write(`\rToken Ledger: scanned ${current}/${total} rollout files`);
763
+ },
764
+ );
765
+ process.stderr.write("\n");
766
+ await writePrivateSnapshot(options.input, snapshot);
767
+ return snapshot;
768
+ } catch (error) {
769
+ if (progressStarted) process.stderr.write("\n");
770
+ throw new Error(
771
+ `Could not refresh local snapshot: ${safeErrorMessage(error, [options.input, options.codexHome])}`,
577
772
  );
578
773
  }
579
- const { collectUsage, writePrivateSnapshot } = await import(
580
- "../lib/token-ledger-collector.mjs"
581
- );
582
- process.stderr.write("Token Ledger: refreshing local snapshot…\n");
583
- const snapshot = await collectUsage(
584
- {
585
- output: options.input,
586
- codexHome: options.codexHome,
587
- includeArchived: options.includeArchived,
588
- since: null,
589
- },
590
- ({ current, total }) => {
591
- process.stderr.write(`\rToken Ledger: scanned ${current}/${total} rollout files`);
592
- },
593
- );
594
- process.stderr.write("\n");
595
- await writePrivateSnapshot(options.input, snapshot);
596
- return snapshot;
597
774
  }
598
775
 
599
- export function snapshotNeedsRefresh(
776
+ export function snapshotNeedsRefresh(snapshotMtimeMs, latestJsonlMtimeMs) {
777
+ return latestJsonlMtimeMs > snapshotMtimeMs;
778
+ }
779
+
780
+ export function snapshotCacheIsFresh(
600
781
  snapshotMtimeMs,
601
- latestSourceMtimeMs,
602
- cachedSourceFingerprint,
603
- expectedSourceFingerprint,
604
- cachedSourceFileCount,
605
- currentSourceFileCount,
782
+ nowMs = Date.now(),
606
783
  ) {
607
784
  return (
608
- cachedSourceFingerprint !== expectedSourceFingerprint ||
609
- cachedSourceFileCount !== currentSourceFileCount ||
610
- latestSourceMtimeMs > snapshotMtimeMs
785
+ Number.isFinite(snapshotMtimeMs) &&
786
+ Number.isFinite(nowMs) &&
787
+ snapshotMtimeMs <= nowMs &&
788
+ nowMs - snapshotMtimeMs < SNAPSHOT_CACHE_MAX_AGE_MS
611
789
  );
612
790
  }
613
791
 
792
+ function snapshotAgeLabel(ageMs) {
793
+ if (ageMs < 60 * 1_000) return "now";
794
+ const minutes = Math.floor(ageMs / (60 * 1_000));
795
+ if (minutes < 60) return `${minutes}m old`;
796
+ const hours = Math.floor(minutes / 60);
797
+ if (hours < 24) return `${hours}h old`;
798
+ return `${Math.floor(hours / 24)}d old`;
799
+ }
800
+
801
+ export function snapshotFreshness(snapshot = {}, nowMs = Date.now()) {
802
+ const generatedAtMs = typeof snapshot.generatedAt === "string"
803
+ ? Date.parse(snapshot.generatedAt)
804
+ : NaN;
805
+ if (
806
+ !Number.isFinite(generatedAtMs) ||
807
+ !Number.isFinite(nowMs) ||
808
+ generatedAtMs > nowMs
809
+ ) {
810
+ return { status: "unknown", ageLabel: "age unknown" };
811
+ }
812
+ return {
813
+ status: snapshotCacheIsFresh(generatedAtMs, nowMs) ? "fresh" : "stale",
814
+ ageLabel: snapshotAgeLabel(nowMs - generatedAtMs),
815
+ };
816
+ }
817
+
614
818
  async function loadSnapshot(options) {
615
819
  if (options.refresh) {
616
820
  return refreshSnapshot(options);
617
821
  }
618
822
  if (!existsSync(options.input)) {
619
823
  if (options.inputExplicit || !options.autoRefresh) {
620
- throw new Error(`Snapshot not found: ${sanitizeTerminalText(options.input)}`);
824
+ throw new Error(
825
+ `Snapshot not found: ${safeDisplayLabel(options.input, "snapshot")}`,
826
+ );
621
827
  }
622
828
  return refreshSnapshot(options);
623
829
  }
@@ -625,48 +831,186 @@ async function loadSnapshot(options) {
625
831
  return readSnapshot(options.input);
626
832
  }
627
833
 
628
- const { sourceFingerprint, sourceState } = await import(
629
- "../lib/token-ledger-collector.mjs"
630
- );
631
- const [snapshotStat, currentSourceState, snapshot] = await Promise.all([
632
- stat(options.input),
633
- sourceState(options.codexHome, options.includeArchived),
634
- readSnapshot(options.input),
635
- ]);
636
- if (snapshotNeedsRefresh(
637
- snapshotStat.mtimeMs,
638
- currentSourceState.latestMtimeMs,
639
- snapshot.provenance?.sourceFingerprint,
640
- sourceFingerprint(options.codexHome, options.includeArchived),
641
- snapshot.coverage?.sourceFileCount,
642
- currentSourceState.fileCount,
643
- )) {
834
+ let snapshotStat;
835
+ try {
836
+ snapshotStat = await stat(options.input);
837
+ } catch (error) {
838
+ throw new Error(
839
+ `Could not inspect snapshot ${safeDisplayLabel(options.input, "snapshot")}: ${safeErrorMessage(error, [options.input])}`,
840
+ );
841
+ }
842
+ if (snapshotCacheIsFresh(snapshotStat.mtimeMs)) {
843
+ return readSnapshot(options.input);
844
+ }
845
+
846
+ const { latestSourceModifiedAt } = await import("../lib/token-ledger-importer.mjs");
847
+ let latestSourceMtimeMs;
848
+ try {
849
+ latestSourceMtimeMs = await latestSourceModifiedAt(
850
+ options.codexHome,
851
+ options.includeArchived,
852
+ );
853
+ } catch (error) {
854
+ throw new Error(
855
+ `Could not inspect local Codex source: ${safeErrorMessage(error, [options.codexHome])}`,
856
+ );
857
+ }
858
+ if (snapshotNeedsRefresh(snapshotStat.mtimeMs, latestSourceMtimeMs)) {
644
859
  return refreshSnapshot(options);
645
860
  }
646
- return snapshot;
861
+ return readSnapshot(options.input);
647
862
  }
648
863
 
649
- function render(options, snapshot, bounds, events, rows, allRows) {
650
- return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
864
+ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
865
+ if (options.view === "trend") {
866
+ const trend = buildUsageTrend(snapshot, bounds);
867
+ if (options.image) {
868
+ return renderTrendImage({
869
+ snapshot,
870
+ bounds,
871
+ trend,
872
+ days: options.trendDays,
873
+ options,
874
+ });
875
+ }
876
+ return renderTrendCombo({
877
+ snapshot,
878
+ bounds,
879
+ trend,
880
+ days: options.trendDays,
881
+ options,
882
+ });
883
+ }
884
+ if (!options.legacyPlot) {
885
+ return renderTerminal({
886
+ options,
887
+ snapshot,
888
+ snapshotFreshness: freshness,
889
+ bounds,
890
+ events,
891
+ rows,
892
+ allRows,
893
+ });
894
+ }
895
+ const enabled = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
896
+ const summary = totalSummary(events);
897
+ const totalTokens = summary.totalTokens;
898
+ const dateLabel = options.range === "rolling24h"
899
+ ? "last 24 hours"
900
+ : options.range === "week"
901
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
902
+ : new Intl.DateTimeFormat("en-US", {
903
+ timeZone: bounds.timeZone,
904
+ weekday: "short",
905
+ month: "short",
906
+ day: "numeric",
907
+ year: "numeric",
908
+ }).format(bounds.start);
909
+ const unit = chartUnit(rows[0]?.totalTokens ?? 0);
910
+ const shares = rows.map((row) =>
911
+ totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
912
+ );
913
+ const chart = runYouPlot(rows, options, dateLabel, unit).trimEnd();
914
+
915
+ const header = [
916
+ `Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
917
+ `${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${events.length.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
918
+ `Source: ${sourceLabel(options.input, snapshot)}`,
919
+ "",
920
+ chart,
921
+ "",
922
+ `Model mix · colors: ${colorize("Sol", MODEL_COLORS.sol, enabled)} ${colorize("Luna", MODEL_COLORS.luna, enabled)} ${colorize("Terra", MODEL_COLORS.terra, enabled)} ${colorize("GPT", MODEL_COLORS["gpt-5.5"], enabled)} ${colorize("Other", MODEL_COLORS.other, enabled)}`,
923
+ ];
924
+
925
+ const details = rows.map((row, index) => {
926
+ const knownCreditShare =
927
+ summary.rateCardCredits > 0 && row.rateCardCredits > 0
928
+ ? ` · ${percent((row.rateCardCredits / summary.rateCardCredits) * 100)} credits`
929
+ : "";
930
+ return `${String(index + 1).padStart(2, " ")} ${row.displayProject} · ${compact(row.totalTokens)} · ${percent(shares[index])} · ${row.threads.toLocaleString()} threads${knownCreditShare}\n ${modelMix(row, enabled)}`;
931
+ });
932
+
933
+ return `${header.join("\n")}\n\n${details.join("\n")}`;
934
+ }
935
+
936
+ function boundsForOptions(options, now = new Date()) {
937
+ if (options.view === "trend") {
938
+ return multiDayBounds(options.date, options.timeZone, options.trendDays);
939
+ }
940
+ if (options.range === "week") {
941
+ return weekBounds(options.date, options.timeZone);
942
+ }
943
+ if (options.range === "rolling24h") {
944
+ return rolling24hBounds(now, options.timeZone);
945
+ }
946
+ return dayBounds(options.date, options.timeZone);
651
947
  }
652
948
 
653
- export async function run(options) {
654
- if (options.range !== "all") boundsForOptions(options);
949
+ function rangeDescription(options, bounds) {
950
+ if (options.range === "rolling24h") return "the last 24 hours";
951
+ if (bounds.startDateString && bounds.endDateString) {
952
+ return `${bounds.startDateString} through ${bounds.endDateString}`;
953
+ }
954
+ return bounds.dateString;
955
+ }
956
+
957
+ export async function run(options, { nowMs } = {}) {
958
+ const hasInjectedNow = nowMs !== undefined;
959
+ const now = new Date(hasInjectedNow ? nowMs : Date.now());
960
+ const bounds = boundsForOptions(options, now);
655
961
  const snapshot = await loadSnapshot(options);
656
- const bounds = boundsForOptions(options, snapshot);
657
962
  const events = filterDayEvents(snapshot, bounds);
658
963
  if (events.length === 0) {
659
- return emptyState(options, snapshot, bounds);
964
+ return [
965
+ `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
966
+ `Source: ${sourceLabel(options.input, snapshot)}`,
967
+ ].join("\n");
660
968
  }
661
969
  const allRows = aggregateProjects(snapshot, events, options);
662
970
  const rows = allRows.slice(0, options.top);
663
- return render(options, snapshot, bounds, events, rows, allRows);
971
+ const writingImage = options.view === "trend" && options.image;
972
+ const outputPath = writingImage
973
+ ? options.imageOutput ??
974
+ resolve(
975
+ process.cwd(),
976
+ `token-ledger-${options.report ? "report" : "trend"}-${options.trendDays}d.png`,
977
+ )
978
+ : null;
979
+ const imageLabel = options.report ? "report" : "trend image";
980
+ if (writingImage) {
981
+ process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
982
+ }
983
+ const output = render(
984
+ options,
985
+ snapshot,
986
+ bounds,
987
+ events,
988
+ rows,
989
+ allRows,
990
+ snapshotFreshness(
991
+ snapshot,
992
+ hasInjectedNow ? now.getTime() : Date.now(),
993
+ ),
994
+ );
995
+ if (writingImage) {
996
+ await mkdir(dirname(outputPath), { recursive: true });
997
+ process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
998
+ await writeTrendPng(output, outputPath);
999
+ process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
1000
+ return [
1001
+ `Wrote ${options.report ? "report" : "trend image"}: ${outputPath}`,
1002
+ `Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
1003
+ ].join("\n");
1004
+ }
1005
+ return output;
664
1006
  }
665
1007
 
666
1008
  function shouldUseInteractive(options) {
667
1009
  return Boolean(
668
1010
  !options.static &&
1011
+ options.view !== "trend" &&
669
1012
  !options.plain &&
1013
+ !options.legacyPlot &&
670
1014
  !process.env.NO_COLOR &&
671
1015
  process.stdin.isTTY &&
672
1016
  process.stdout.isTTY,
@@ -674,18 +1018,22 @@ function shouldUseInteractive(options) {
674
1018
  }
675
1019
 
676
1020
  async function runInteractive(options) {
677
- if (options.range !== "all") boundsForOptions(options);
1021
+ const bounds = boundsForOptions(options);
678
1022
  const snapshot = await loadSnapshot(options);
679
- const bounds = boundsForOptions(options, snapshot);
680
1023
  const events = filterDayEvents(snapshot, bounds);
681
1024
  if (events.length === 0) {
682
- process.stdout.write(`${emptyState(options, snapshot, bounds)}\n`);
1025
+ process.stdout.write([
1026
+ `No model-call events found for ${rangeDescription(options, bounds)} (${bounds.timeZone}).`,
1027
+ `Source: ${sourceLabel(options.input, snapshot)}`,
1028
+ "",
1029
+ ].join("\n"));
683
1030
  return;
684
1031
  }
685
1032
  const allRows = aggregateProjects(snapshot, events, options);
686
1033
  await startInteractive({
687
1034
  options,
688
1035
  snapshot,
1036
+ snapshotFreshness: snapshotFreshness(snapshot),
689
1037
  bounds,
690
1038
  events,
691
1039
  rows: allRows.slice(0, options.top),
@@ -701,18 +1049,18 @@ async function main() {
701
1049
  process.stdout.write(`${usage()}\n`);
702
1050
  return;
703
1051
  }
704
- if (options.version) {
705
- process.stdout.write(`${VERSION}\n`);
706
- return;
707
- }
708
1052
  if (shouldUseInteractive(options)) {
709
1053
  await runInteractive(options);
710
1054
  } else {
711
- process.stdout.write(`${await run({ ...options, static: true })}\n`);
1055
+ process.stdout.write(`${await run(options)}\n`);
712
1056
  }
713
1057
  } catch (error) {
714
1058
  process.stderr.write(
715
- `Token Ledger CLI failed: ${sanitizeTerminalText(error.message)}\n\n${usage()}\n`,
1059
+ `Token Ledger CLI failed: ${safeErrorMessage(error, [
1060
+ options?.input,
1061
+ options?.codexHome,
1062
+ options?.imageOutput,
1063
+ ])}\n\n${usage()}\n`,
716
1064
  );
717
1065
  process.exitCode = 1;
718
1066
  }