opencode-token-tracker 1.5.7 → 1.6.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.
package/README.md CHANGED
@@ -152,8 +152,76 @@ Breakdown options (`--by`):
152
152
  - `agent` - Group by agent (e.g., sisyphus, coder)
153
153
  - `provider` - Group by provider (e.g., anthropic, openai)
154
154
  - `daily` - Show day-by-day breakdown
155
+ - `session` - Group by session ID
155
156
  - `all` - Show all breakdowns
156
157
 
158
+ ### Trend Chart
159
+
160
+ Visualize your daily token usage and cost over time:
161
+
162
+ ```bash
163
+ # 30-day cost trend (default)
164
+ opencode-tokens trend
165
+
166
+ # 7-day token count trend
167
+ opencode-tokens trend --days 7 --metric tokens
168
+
169
+ # Compact chart
170
+ opencode-tokens trend --width 40
171
+ ```
172
+
173
+ Options:
174
+ - `--days N` — Number of days to chart (default 30)
175
+ - `--metric` — `cost` (default), `tokens`, or `messages`
176
+ - `--width W` — Chart width in characters (default 60)
177
+
178
+ ### Data Export
179
+
180
+ Export your token usage data for analysis:
181
+
182
+ ```bash
183
+ # Export all data as CSV
184
+ opencode-tokens export
185
+
186
+ # Export this month as JSON
187
+ opencode-tokens export --format json --period month
188
+
189
+ # Export to file
190
+ opencode-tokens export --format csv --output usage.csv
191
+ ```
192
+
193
+ Options:
194
+ - `--format` — `csv` (default) or `json`
195
+ - `--period` — `today`, `week`, `month`, `all` (default)
196
+ - `--output FILE` — Write to file instead of stdout
197
+
198
+ ### Config Management
199
+
200
+ Manage budget and toast settings directly from the CLI:
201
+
202
+ ```bash
203
+ # Show current config
204
+ opencode-tokens config
205
+
206
+ # Set daily budget to $10
207
+ opencode-tokens config set budget.daily 10
208
+
209
+ # Disable toast notifications
210
+ opencode-tokens config set toast.enabled false
211
+
212
+ # Check a value
213
+ opencode-tokens config get budget.warnAt
214
+
215
+ # Reset to default
216
+ opencode-tokens config unset budget.daily
217
+ ```
218
+
219
+ Settable keys:
220
+ - `budget.daily`, `budget.weekly`, `budget.monthly`, `budget.warnAt`
221
+ - `toast.enabled`, `toast.duration`, `toast.showOnIdle`
222
+
223
+ Config changes are automatically backed up to `token-tracker.json.bak`.
224
+
157
225
  ### Pricing & Config Commands
158
226
 
159
227
  ```bash
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "../lib/shared.js";
3
- import { readFileSync, existsSync, writeFileSync, openSync, readSync, closeSync, statSync } from "fs";
3
+ import { readFileSync, existsSync, writeFileSync, copyFileSync, mkdirSync, openSync, readSync, closeSync, statSync } from "fs";
4
4
  import { join } from "path";
5
5
  import { homedir } from "os";
6
6
  const CONFIG_DIR = join(homedir(), ".config", "opencode");
@@ -15,6 +15,64 @@ function padRight(str, len) {
15
15
  function padLeft(str, len) {
16
16
  return str.length >= len ? str : " ".repeat(len - str.length) + str;
17
17
  }
18
+ function truncateSessionId(sessionId) {
19
+ if (!sessionId)
20
+ return "unknown";
21
+ return sessionId.length > 16 ? sessionId.slice(0, 14) + "…" : sessionId;
22
+ }
23
+ function parseArgs(args) {
24
+ const positional = [];
25
+ const flags = new Map();
26
+ let i = 0;
27
+ while (i < args.length) {
28
+ const arg = args[i];
29
+ if (arg === "--help" || arg === "-h") {
30
+ flags.set("help", true);
31
+ i++;
32
+ continue;
33
+ }
34
+ if (arg.startsWith("--")) {
35
+ const eqIndex = arg.indexOf("=");
36
+ if (eqIndex !== -1) {
37
+ flags.set(arg.slice(2, eqIndex), arg.slice(eqIndex + 1));
38
+ }
39
+ else {
40
+ const next = args[i + 1];
41
+ if (next && !next.startsWith("-")) {
42
+ flags.set(arg.slice(2), next);
43
+ i++;
44
+ }
45
+ else {
46
+ flags.set(arg.slice(2), true);
47
+ }
48
+ }
49
+ i++;
50
+ continue;
51
+ }
52
+ if (arg.startsWith("-") && arg.length === 2 && arg !== "--") {
53
+ const next = args[i + 1];
54
+ if (next && !next.startsWith("-")) {
55
+ flags.set(arg.slice(1), next);
56
+ i++;
57
+ }
58
+ else {
59
+ flags.set(arg.slice(1), true);
60
+ }
61
+ i++;
62
+ continue;
63
+ }
64
+ positional.push(arg);
65
+ i++;
66
+ }
67
+ return { command: positional[0] || "", flags, positional };
68
+ }
69
+ function flagValue(flags, name) {
70
+ const v = flags.get(name);
71
+ return typeof v === "string" ? v : undefined;
72
+ }
73
+ function flagBool(flags, name) {
74
+ return flags.has(name);
75
+ }
18
76
  // ============================================================================
19
77
  // Data Loading
20
78
  // ============================================================================
@@ -246,6 +304,9 @@ function cmdStats(period, breakdown) {
246
304
  case "daily":
247
305
  printDailyBreakdown(entries);
248
306
  break;
307
+ case "session":
308
+ printTable("By Session", groupBy(entries, (e) => truncateSessionId(e.sessionId)), "Session");
309
+ break;
249
310
  case "all":
250
311
  printTable("By Model", groupBy(entries, (e) => e.model ?? "unknown"), "Model");
251
312
  printTable("By Agent", groupBy(entries, (e) => e.agent ?? "unknown"), "Agent");
@@ -359,7 +420,8 @@ function cmdModels() {
359
420
  console.log(` default = unknown model, using $1/$4 per 1M tokens`);
360
421
  console.log();
361
422
  }
362
- function cmdConfig(action) {
423
+ function cmdConfig(positional) {
424
+ const action = positional[1];
363
425
  const config = loadConfig();
364
426
  const entries = loadEntries();
365
427
  if (action === "init" || action === "generate") {
@@ -466,6 +528,71 @@ function cmdConfig(action) {
466
528
  }
467
529
  return;
468
530
  }
531
+ if (action === "get") {
532
+ const key = positional[2];
533
+ if (!key) {
534
+ console.log("\n Usage: opencode-tokens config get <key>\n");
535
+ return;
536
+ }
537
+ if (!SETTABLE_KEYS[key]) {
538
+ console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
539
+ return;
540
+ }
541
+ const value = resolveConfigKey(config, key);
542
+ console.log(`\n ${key} = ${JSON.stringify(value)} (default: ${JSON.stringify(SETTABLE_KEYS[key].default)})\n`);
543
+ return;
544
+ }
545
+ if (action === "set") {
546
+ const key = positional[2];
547
+ const rawValue = positional[3];
548
+ if (!key || !rawValue) {
549
+ console.log("\n Usage: opencode-tokens config set <key> <value>\n");
550
+ return;
551
+ }
552
+ const spec = SETTABLE_KEYS[key];
553
+ if (!spec) {
554
+ console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
555
+ return;
556
+ }
557
+ const value = parseConfigValue(rawValue);
558
+ if (typeof value !== spec.type) {
559
+ console.log(`\n Invalid type: expected ${spec.type}, got ${typeof value}\n`);
560
+ return;
561
+ }
562
+ if (typeof value === "number") {
563
+ if (value < 0) {
564
+ console.log(`\n Value must be >= 0\n`);
565
+ return;
566
+ }
567
+ if (spec.max !== undefined && value > spec.max) {
568
+ console.log(`\n Value must be <= ${spec.max}\n`);
569
+ return;
570
+ }
571
+ }
572
+ applyConfigSet(key, value, config);
573
+ console.log(`\n Set ${key} = ${JSON.stringify(value)}\n`);
574
+ return;
575
+ }
576
+ if (action === "unset") {
577
+ const key = positional[2];
578
+ if (!key) {
579
+ console.log("\n Usage: opencode-tokens config unset <key>\n");
580
+ return;
581
+ }
582
+ const spec = SETTABLE_KEYS[key];
583
+ if (!spec) {
584
+ console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
585
+ return;
586
+ }
587
+ applyConfigUnset(key, config);
588
+ console.log(`\n Unset ${key} (reverted to default)\n`);
589
+ return;
590
+ }
591
+ if (action && action !== "show") {
592
+ console.log(`\n Unknown config action: ${action}`);
593
+ console.log(` Usage: opencode-tokens config [show|init|generate|get|set|unset]\n`);
594
+ return;
595
+ }
469
596
  // Show current config
470
597
  console.log(`
471
598
  Current Configuration
@@ -481,10 +608,155 @@ function cmdConfig(action) {
481
608
  console.log();
482
609
  }
483
610
  console.log(` Commands:`);
484
- console.log(` opencode-tokens config init Show example config with explanation`);
485
- console.log(` opencode-tokens config generate Create config file`);
611
+ console.log(` opencode-tokens config show Show current config`);
612
+ console.log(` opencode-tokens config init Show example config with explanation`);
613
+ console.log(` opencode-tokens config generate Create config file`);
614
+ console.log(` opencode-tokens config get <key> Get a config value`);
615
+ console.log(` opencode-tokens config set <key> <value> Set a config value`);
616
+ console.log(` opencode-tokens config unset <key> Reset a config value to default`);
486
617
  console.log();
487
618
  }
619
+ const SETTABLE_KEYS = {
620
+ "budget.daily": { type: "number", path: ["budget", "daily"], default: undefined },
621
+ "budget.weekly": { type: "number", path: ["budget", "weekly"], default: undefined },
622
+ "budget.monthly": { type: "number", path: ["budget", "monthly"], default: undefined },
623
+ "budget.warnAt": { type: "number", path: ["budget", "warnAt"], default: 0.8, max: 1 },
624
+ "toast.enabled": { type: "boolean", path: ["toast", "enabled"], default: true },
625
+ "toast.duration": { type: "number", path: ["toast", "duration"], default: 3000 },
626
+ "toast.showOnIdle": { type: "boolean", path: ["toast", "showOnIdle"], default: true },
627
+ };
628
+ function parseConfigValue(s) {
629
+ if (s === "true")
630
+ return true;
631
+ if (s === "false")
632
+ return false;
633
+ if (s === "null")
634
+ return null;
635
+ if (/^-?\d+(\.\d+)?$/.test(s))
636
+ return Number(s);
637
+ return s;
638
+ }
639
+ function resolveConfigKey(config, key) {
640
+ const spec = SETTABLE_KEYS[key];
641
+ if (!spec)
642
+ return undefined;
643
+ let obj = config;
644
+ for (let i = 0; i < spec.path.length - 1; i++) {
645
+ obj = obj[spec.path[i]];
646
+ if (!obj)
647
+ return spec.default;
648
+ }
649
+ return obj[spec.path[spec.path.length - 1]] ?? spec.default;
650
+ }
651
+ function applyConfigSet(key, value, config) {
652
+ const spec = SETTABLE_KEYS[key];
653
+ const fullConfig = loadOrInitConfig();
654
+ let obj = fullConfig;
655
+ for (let i = 0; i < spec.path.length - 1; i++) {
656
+ if (!obj[spec.path[i]])
657
+ obj[spec.path[i]] = {};
658
+ obj = obj[spec.path[i]];
659
+ }
660
+ obj[spec.path[spec.path.length - 1]] = value;
661
+ saveConfig(fullConfig);
662
+ }
663
+ function applyConfigUnset(key, config) {
664
+ const spec = SETTABLE_KEYS[key];
665
+ const fullConfig = loadOrInitConfig();
666
+ let obj = fullConfig;
667
+ for (let i = 0; i < spec.path.length - 1; i++) {
668
+ if (!obj[spec.path[i]])
669
+ return;
670
+ obj = obj[spec.path[i]];
671
+ }
672
+ delete obj[spec.path[spec.path.length - 1]];
673
+ saveConfig(fullConfig);
674
+ }
675
+ function loadOrInitConfig() {
676
+ if (existsSync(CONFIG_FILE)) {
677
+ try {
678
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
679
+ }
680
+ catch { }
681
+ }
682
+ return {};
683
+ }
684
+ function saveConfig(raw) {
685
+ const dir = join(homedir(), ".config", "opencode");
686
+ if (!existsSync(dir)) {
687
+ mkdirSync(dir, { recursive: true });
688
+ }
689
+ if (existsSync(CONFIG_FILE)) {
690
+ copyFileSync(CONFIG_FILE, CONFIG_FILE + ".bak");
691
+ }
692
+ writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, 2) + "\n");
693
+ }
694
+ // ============================================================================
695
+ // Export
696
+ // ============================================================================
697
+ function cmdExport(flags) {
698
+ const format = flagValue(flags, "format") || "csv";
699
+ const period = flagValue(flags, "period") || "all";
700
+ const outputFile = flagValue(flags, "output");
701
+ const now = new Date();
702
+ let since;
703
+ switch (period) {
704
+ case "today":
705
+ since = getStartOfDay(now);
706
+ break;
707
+ case "week":
708
+ since = getStartOfWeek(now);
709
+ break;
710
+ case "month":
711
+ since = getStartOfMonth(now);
712
+ break;
713
+ }
714
+ const entries = loadEntries(since);
715
+ if (entries.length === 0) {
716
+ console.log(`\n No data to export for ${period}\n`);
717
+ return;
718
+ }
719
+ let output;
720
+ if (format === "json") {
721
+ output = JSON.stringify(entries, null, 2);
722
+ }
723
+ else {
724
+ const headers = ["timestamp", "date", "session_id", "message_id", "role", "agent", "model", "provider", "input", "output", "reasoning", "cache_read", "cache_write", "cost"];
725
+ const rows = entries.map(e => [
726
+ e._ts,
727
+ new Date(e._ts).toISOString().slice(0, 10),
728
+ e.sessionId ?? "",
729
+ e.messageId ?? "",
730
+ e.role ?? "",
731
+ e.agent ?? "",
732
+ e.model ?? "",
733
+ e.provider ?? "",
734
+ e.input ?? 0,
735
+ e.output ?? 0,
736
+ e.reasoning ?? 0,
737
+ e.cacheRead ?? 0,
738
+ e.cacheWrite ?? 0,
739
+ e.cost ?? 0,
740
+ ].map(csvEscape).join(","));
741
+ output = [headers.join(","), ...rows].join("\n") + "\n";
742
+ }
743
+ if (outputFile) {
744
+ writeFileSync(outputFile, output);
745
+ console.log(`\n Exported ${entries.length} entries to ${outputFile}\n`);
746
+ }
747
+ else {
748
+ process.stdout.write(output);
749
+ }
750
+ }
751
+ function csvEscape(v) {
752
+ if (v == null)
753
+ return "";
754
+ const s = String(v);
755
+ if (/[",\n\r]/.test(s)) {
756
+ return `"${s.replace(/"/g, '""')}"`;
757
+ }
758
+ return s;
759
+ }
488
760
  function cmdBudget() {
489
761
  const config = loadConfig();
490
762
  const budget = config.budget;
@@ -584,64 +856,206 @@ function cmdHelp() {
584
856
  (default) Show usage statistics
585
857
  budget Show budget status (daily/weekly/monthly)
586
858
  pricing Show built-in pricing table
587
- models Show your used models and their pricing status
588
- config Show/generate configuration
859
+ models Show your used models and their pricing status
860
+ config Show/generate/modify configuration
861
+ export Export token data to CSV/JSON
862
+ trend Show daily cost/tokens trend chart
589
863
 
590
864
  Statistics Options:
591
865
  today Show today's usage
592
- week Show this week's usage
866
+ week Show this week's usage
593
867
  month Show this month's usage
594
868
  all Show all-time usage (default)
595
-
596
- --by <type> Group by: model, agent, provider, daily, all
869
+
870
+ --by <type> Group by: model, agent, provider, session, daily, all
871
+
872
+ Export Options:
873
+ --format csv (default) or json
874
+ --period today, week, month, all (default)
875
+ --output Write to file instead of stdout
876
+
877
+ Trend Options:
878
+ --days N Number of days to chart (default 30)
879
+ --metric cost (default), tokens, or messages
880
+ --width W Chart width in characters (default 60)
881
+
882
+ Config Sub-commands:
883
+ config show Show current config
884
+ config init Show example config with explanation
885
+ config generate Create config file
886
+ config get <key> Get a config value
887
+ config set <key> <value> Set a config value
888
+ config unset <key> Reset a config value to default
889
+
890
+ Settable config keys:
891
+ budget.daily, budget.weekly, budget.monthly, budget.warnAt
892
+ toast.enabled, toast.duration, toast.showOnIdle
597
893
 
598
894
  Examples:
599
- opencode-tokens # All-time summary
600
- opencode-tokens budget # Check budget status
601
- opencode-tokens today # Today's summary
602
- opencode-tokens week --by model # This week, by model
603
- opencode-tokens pricing # Show pricing table
604
- opencode-tokens models # Show your models
605
- opencode-tokens config init # Generate example config
895
+ opencode-tokens # All-time summary
896
+ opencode-tokens today --by model # Today by model
897
+ opencode-tokens week --by session # This week by session
898
+ opencode-tokens trend --days 7 # 7-day cost trend
899
+ opencode-tokens export --format csv # Export all data as CSV
900
+ opencode-tokens config set budget.daily 10 # Set daily budget to $10
901
+ opencode-tokens config get toast.enabled # Check if toast is enabled
606
902
  `);
607
903
  }
608
904
  // ============================================================================
609
- // Main
905
+ // Trend
610
906
  // ============================================================================
611
- function main() {
612
- const args = process.argv.slice(2);
613
- const command = args[0];
614
- if (args.includes("--help") || args.includes("-h")) {
615
- cmdHelp();
907
+ function cmdTrend(flags) {
908
+ const days = parseInt(String(flagValue(flags, "days") ?? "30"), 10);
909
+ const metric = flagValue(flags, "metric") ?? "cost";
910
+ const width = parseInt(String(flagValue(flags, "width") ?? "60"), 10);
911
+ const since = getStartOfDay(new Date(Date.now() - days * 86400000));
912
+ const entries = loadEntries(since);
913
+ if (entries.length === 0) {
914
+ console.log(`\n (no data in period)\n`);
616
915
  return;
617
916
  }
618
- // Handle subcommands
619
- if (command === "budget") {
620
- cmdBudget();
621
- return;
917
+ // Aggregate by day
918
+ const dayMap = new Map();
919
+ for (const e of entries) {
920
+ const dayStart = getStartOfDay(new Date(e._ts));
921
+ if (!dayMap.has(dayStart)) {
922
+ dayMap.set(dayStart, { cost: 0, tokens: 0, messages: 0 });
923
+ }
924
+ const d = dayMap.get(dayStart);
925
+ d.cost += e.cost ?? 0;
926
+ d.tokens += (e.input ?? 0) + (e.output ?? 0) + (e.reasoning ?? 0);
927
+ d.messages += 1;
622
928
  }
623
- if (command === "pricing") {
624
- cmdPricing();
929
+ const sorted = Array.from(dayMap.entries()).sort(([a], [b]) => a - b);
930
+ if (sorted.length < 2) {
931
+ const only = sorted[0];
932
+ if (only) {
933
+ const v = metric === "tokens" ? formatTokens(only[1].tokens) : metric === "messages" ? String(only[1].messages) : formatCost(only[1].cost);
934
+ console.log(`\n ${new Date(only[0]).toISOString().slice(0, 10)}: ${v}\n`);
935
+ }
625
936
  return;
626
937
  }
627
- if (command === "models") {
628
- cmdModels();
938
+ const values = sorted.map(([, d]) => metric === "tokens" ? d.tokens : metric === "messages" ? d.messages : d.cost);
939
+ const maxVal = Math.max(...values, 1);
940
+ const H = Math.max(5, Math.min(Math.floor(width / 3), 20));
941
+ if (width < 35) {
942
+ // Fallback: simple sparkline
943
+ const chars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
944
+ const spark = values.map(v => chars[Math.min(Math.floor((v / maxVal) * 7), 7)]).join("");
945
+ console.log(`\n ${spark}\n`);
629
946
  return;
630
947
  }
631
- if (command === "config") {
632
- cmdConfig(args[1]);
948
+ // Build chart
949
+ const cols = values.map((v) => ({ value: v, y: Math.round((v / maxVal) * (H - 1)) }));
950
+ const chartWidth = Math.max(width - 12, 20);
951
+ const xStep = Math.max(2, Math.floor(chartWidth / sorted.length));
952
+ const yLabelStep = Math.max(1, Math.floor(H / 5));
953
+ const lines = [];
954
+ for (let row = H - 1; row >= 0; row--) {
955
+ let line = "";
956
+ const valAtRow = (row / (H - 1)) * maxVal;
957
+ const label = row === H - 1 || row === 0 || (H - 1 - row) % yLabelStep === 0
958
+ ? metric === "tokens" ? formatTokens(valAtRow) : metric === "messages" ? String(Math.round(valAtRow)) : formatCost(valAtRow)
959
+ : "";
960
+ line += padLeft(label, 9);
961
+ line += row === 0 ? " ┼" : " ┤";
962
+ for (let ci = 0; ci < cols.length; ci++) {
963
+ const col = cols[ci];
964
+ const nextY = ci < cols.length - 1 ? cols[ci + 1].y : col.y;
965
+ if (col.y === row) {
966
+ if (ci > 0) {
967
+ const prevY = cols[ci - 1].y;
968
+ if (prevY < col.y && nextY <= col.y)
969
+ line += "╭";
970
+ else if (prevY > col.y && nextY >= col.y)
971
+ line += "╰";
972
+ else if (prevY < col.y || nextY < col.y)
973
+ line += "╭";
974
+ else if (prevY > col.y || nextY > col.y)
975
+ line += "╰";
976
+ else
977
+ line += "─";
978
+ }
979
+ else {
980
+ line += nextY > col.y ? "╭" : nextY < col.y ? "╰" : "─";
981
+ }
982
+ if (xStep > 1 && ci < cols.length - 1 && nextY === row) {
983
+ line += "─".repeat(xStep - 1);
984
+ }
985
+ }
986
+ else if (ci > 0 && ci < cols.length) {
987
+ const prevY = cols[ci - 1].y;
988
+ if ((prevY < row && col.y > row) || (prevY > row && col.y < row)) {
989
+ line += prevY < col.y ? "╱" : "╲";
990
+ }
991
+ else {
992
+ line += " ".repeat(xStep > 1 && nextY !== row ? 1 : Math.min(xStep, 1));
993
+ }
994
+ }
995
+ }
996
+ lines.push(line);
997
+ }
998
+ // Bottom axis
999
+ let axis = " ".repeat(9) + " └";
1000
+ axis += "─".repeat(xStep * cols.length);
1001
+ lines.push(axis);
1002
+ // X axis labels
1003
+ const labelStep = Math.max(1, Math.ceil(sorted.length / 6));
1004
+ let xLabels = " ".repeat(11);
1005
+ for (let i = 0; i < cols.length; i++) {
1006
+ if (i % labelStep === 0 || i === cols.length - 1) {
1007
+ const d = new Date(sorted[i][0]);
1008
+ const ds = `${d.getMonth() + 1}/${d.getDate()}`;
1009
+ xLabels += ds;
1010
+ if (i < cols.length - 1)
1011
+ xLabels += " ".repeat(Math.max(1, xStep - ds.length + 1));
1012
+ }
1013
+ }
1014
+ lines.push(xLabels);
1015
+ console.log();
1016
+ for (const l of lines)
1017
+ console.log(" " + l);
1018
+ console.log();
1019
+ }
1020
+ // ============================================================================
1021
+ // Main
1022
+ // ============================================================================
1023
+ function main() {
1024
+ const args = process.argv.slice(2);
1025
+ const parsed = parseArgs(args);
1026
+ if (parsed.flags.has("help")) {
1027
+ cmdHelp();
633
1028
  return;
634
1029
  }
635
- // Parse stats arguments
1030
+ const { command } = parsed;
1031
+ switch (command) {
1032
+ case "budget":
1033
+ cmdBudget();
1034
+ return;
1035
+ case "pricing":
1036
+ cmdPricing();
1037
+ return;
1038
+ case "models":
1039
+ cmdModels();
1040
+ return;
1041
+ case "config":
1042
+ cmdConfig(parsed.positional);
1043
+ return;
1044
+ case "export":
1045
+ cmdExport(parsed.flags);
1046
+ return;
1047
+ case "trend":
1048
+ cmdTrend(parsed.flags);
1049
+ return;
1050
+ }
1051
+ // Default: stats
636
1052
  let period = "all";
637
1053
  let breakdown;
638
- for (let i = 0; i < args.length; i++) {
639
- const arg = args[i];
640
- if (arg === "--by" || arg === "-b") {
641
- breakdown = args[++i];
642
- }
643
- else if (["today", "week", "month", "all"].includes(arg)) {
644
- period = arg;
1054
+ breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
1055
+ for (const p of ["today", "week", "month", "all"]) {
1056
+ if (parsed.positional.includes(p)) {
1057
+ period = p;
1058
+ break;
645
1059
  }
646
1060
  }
647
1061
  cmdStats(period, breakdown);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { execSync } from "child_process";
4
+ import { fileURLToPath } from "url";
5
+ import { join, dirname } from "path";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const CLI = join(__dirname, "..", "bin", "opencode-tokens.js");
8
+ function run(args) {
9
+ return execSync(`node ${CLI} ${args}`, { encoding: "utf-8", timeout: 10000 });
10
+ }
11
+ describe("CLI help and stats", () => {
12
+ it("should show help", () => {
13
+ const out = run("--help");
14
+ assert.ok(out.includes("opencode-tokens - Token usage statistics CLI"));
15
+ assert.ok(out.includes("trend"));
16
+ assert.ok(out.includes("export"));
17
+ assert.ok(out.includes("config set"));
18
+ });
19
+ it("should show stats by session", () => {
20
+ const out = run("--by session");
21
+ assert.ok(out.includes("By Session") || out.includes("No data"));
22
+ });
23
+ it("should show budget", () => {
24
+ const out = run("budget");
25
+ assert.ok(out.includes("Budget Status"));
26
+ });
27
+ });
28
+ describe("CLI config", () => {
29
+ it("should set, get, and unset config value", () => {
30
+ run("config set toast.duration 5000");
31
+ const out = run("config get toast.duration");
32
+ assert.ok(out.includes("5000"));
33
+ run("config unset toast.duration");
34
+ });
35
+ it("should reject unknown config key", () => {
36
+ const out = run("config get nonexistent.key");
37
+ assert.ok(out.includes("Unknown key"));
38
+ });
39
+ });
40
+ describe("CLI export", () => {
41
+ it("should export as CSV", () => {
42
+ const out = run("export --format csv --period today");
43
+ assert.ok(out.includes("timestamp") || out.includes("No data"));
44
+ });
45
+ it("should export as JSON", () => {
46
+ const out = run("export --format json --period today");
47
+ assert.ok(out.startsWith("[") || out.includes("No data"));
48
+ });
49
+ });
50
+ describe("CLI trend", () => {
51
+ it("should show trend chart", () => {
52
+ const out = run("trend --days 7");
53
+ assert.ok(out.includes("┤") || out.includes("(no data"));
54
+ });
55
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-token-tracker",
3
- "version": "1.5.7",
3
+ "version": "1.6.1",
4
4
  "description": "Real-time token usage and cost tracking plugin for OpenCode with Toast notifications and CLI stats",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
13
- "test": "tsc && node --test dist/test/shared.test.js",
13
+ "test": "tsc && node --test dist/test/shared.test.js dist/test/cli.test.js",
14
14
  "prepublishOnly": "npm run build"
15
15
  },
16
16
  "keywords": [