opencode-token-tracker 1.5.7 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,72 @@ 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
+ const value = resolveConfigKey(config, key);
538
+ if (value === undefined) {
539
+ console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
540
+ }
541
+ else {
542
+ console.log(`\n ${key} = ${JSON.stringify(value)}\n`);
543
+ }
544
+ return;
545
+ }
546
+ if (action === "set") {
547
+ const key = positional[2];
548
+ const rawValue = positional[3];
549
+ if (!key || !rawValue) {
550
+ console.log("\n Usage: opencode-tokens config set <key> <value>\n");
551
+ return;
552
+ }
553
+ const spec = SETTABLE_KEYS[key];
554
+ if (!spec) {
555
+ console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
556
+ return;
557
+ }
558
+ const value = parseConfigValue(rawValue);
559
+ if (typeof value !== spec.type) {
560
+ console.log(`\n Invalid type: expected ${spec.type}, got ${typeof value}\n`);
561
+ return;
562
+ }
563
+ if (typeof value === "number") {
564
+ if (value < 0) {
565
+ console.log(`\n Value must be >= 0\n`);
566
+ return;
567
+ }
568
+ if (spec.max !== undefined && value > spec.max) {
569
+ console.log(`\n Value must be <= ${spec.max}\n`);
570
+ return;
571
+ }
572
+ }
573
+ applyConfigSet(key, value, config);
574
+ console.log(`\n Set ${key} = ${JSON.stringify(value)}\n`);
575
+ return;
576
+ }
577
+ if (action === "unset") {
578
+ const key = positional[2];
579
+ if (!key) {
580
+ console.log("\n Usage: opencode-tokens config unset <key>\n");
581
+ return;
582
+ }
583
+ const spec = SETTABLE_KEYS[key];
584
+ if (!spec) {
585
+ console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
586
+ return;
587
+ }
588
+ applyConfigUnset(key, config);
589
+ console.log(`\n Unset ${key} (reverted to default)\n`);
590
+ return;
591
+ }
592
+ if (action && action !== "show") {
593
+ console.log(`\n Unknown config action: ${action}`);
594
+ console.log(` Usage: opencode-tokens config [show|init|generate|get|set|unset]\n`);
595
+ return;
596
+ }
469
597
  // Show current config
470
598
  console.log(`
471
599
  Current Configuration
@@ -481,10 +609,155 @@ function cmdConfig(action) {
481
609
  console.log();
482
610
  }
483
611
  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`);
612
+ console.log(` opencode-tokens config show Show current config`);
613
+ console.log(` opencode-tokens config init Show example config with explanation`);
614
+ console.log(` opencode-tokens config generate Create config file`);
615
+ console.log(` opencode-tokens config get <key> Get a config value`);
616
+ console.log(` opencode-tokens config set <key> <value> Set a config value`);
617
+ console.log(` opencode-tokens config unset <key> Reset a config value to default`);
486
618
  console.log();
487
619
  }
620
+ const SETTABLE_KEYS = {
621
+ "budget.daily": { type: "number", path: ["budget", "daily"], default: undefined },
622
+ "budget.weekly": { type: "number", path: ["budget", "weekly"], default: undefined },
623
+ "budget.monthly": { type: "number", path: ["budget", "monthly"], default: undefined },
624
+ "budget.warnAt": { type: "number", path: ["budget", "warnAt"], default: 0.8, max: 1 },
625
+ "toast.enabled": { type: "boolean", path: ["toast", "enabled"], default: true },
626
+ "toast.duration": { type: "number", path: ["toast", "duration"], default: 3000 },
627
+ "toast.showOnIdle": { type: "boolean", path: ["toast", "showOnIdle"], default: true },
628
+ };
629
+ function parseConfigValue(s) {
630
+ if (s === "true")
631
+ return true;
632
+ if (s === "false")
633
+ return false;
634
+ if (s === "null")
635
+ return null;
636
+ if (/^-?\d+(\.\d+)?$/.test(s))
637
+ return Number(s);
638
+ return s;
639
+ }
640
+ function resolveConfigKey(config, key) {
641
+ const spec = SETTABLE_KEYS[key];
642
+ if (!spec)
643
+ return undefined;
644
+ let obj = config;
645
+ for (let i = 0; i < spec.path.length - 1; i++) {
646
+ obj = obj[spec.path[i]];
647
+ if (!obj)
648
+ return spec.default;
649
+ }
650
+ return obj[spec.path[spec.path.length - 1]] ?? spec.default;
651
+ }
652
+ function applyConfigSet(key, value, config) {
653
+ const spec = SETTABLE_KEYS[key];
654
+ const fullConfig = loadOrInitConfig();
655
+ let obj = fullConfig;
656
+ for (let i = 0; i < spec.path.length - 1; i++) {
657
+ if (!obj[spec.path[i]])
658
+ obj[spec.path[i]] = {};
659
+ obj = obj[spec.path[i]];
660
+ }
661
+ obj[spec.path[spec.path.length - 1]] = value;
662
+ saveConfig(fullConfig);
663
+ }
664
+ function applyConfigUnset(key, config) {
665
+ const spec = SETTABLE_KEYS[key];
666
+ const fullConfig = loadOrInitConfig();
667
+ let obj = fullConfig;
668
+ for (let i = 0; i < spec.path.length - 1; i++) {
669
+ if (!obj[spec.path[i]])
670
+ return;
671
+ obj = obj[spec.path[i]];
672
+ }
673
+ delete obj[spec.path[spec.path.length - 1]];
674
+ saveConfig(fullConfig);
675
+ }
676
+ function loadOrInitConfig() {
677
+ if (existsSync(CONFIG_FILE)) {
678
+ try {
679
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
680
+ }
681
+ catch { }
682
+ }
683
+ return {};
684
+ }
685
+ function saveConfig(raw) {
686
+ const dir = join(homedir(), ".config", "opencode");
687
+ if (!existsSync(dir)) {
688
+ mkdirSync(dir, { recursive: true });
689
+ }
690
+ if (existsSync(CONFIG_FILE)) {
691
+ copyFileSync(CONFIG_FILE, CONFIG_FILE + ".bak");
692
+ }
693
+ writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, 2) + "\n");
694
+ }
695
+ // ============================================================================
696
+ // Export
697
+ // ============================================================================
698
+ function cmdExport(flags) {
699
+ const format = flagValue(flags, "format") || "csv";
700
+ const period = flagValue(flags, "period") || "all";
701
+ const outputFile = flagValue(flags, "output");
702
+ const now = new Date();
703
+ let since;
704
+ switch (period) {
705
+ case "today":
706
+ since = getStartOfDay(now);
707
+ break;
708
+ case "week":
709
+ since = getStartOfWeek(now);
710
+ break;
711
+ case "month":
712
+ since = getStartOfMonth(now);
713
+ break;
714
+ }
715
+ const entries = loadEntries(since);
716
+ if (entries.length === 0) {
717
+ console.log(`\n No data to export for ${period}\n`);
718
+ return;
719
+ }
720
+ let output;
721
+ if (format === "json") {
722
+ output = JSON.stringify(entries, null, 2);
723
+ }
724
+ else {
725
+ const headers = ["timestamp", "date", "session_id", "message_id", "role", "agent", "model", "provider", "input", "output", "reasoning", "cache_read", "cache_write", "cost"];
726
+ const rows = entries.map(e => [
727
+ e._ts,
728
+ new Date(e._ts).toISOString().slice(0, 10),
729
+ e.sessionId ?? "",
730
+ e.messageId ?? "",
731
+ e.role ?? "",
732
+ e.agent ?? "",
733
+ e.model ?? "",
734
+ e.provider ?? "",
735
+ e.input ?? 0,
736
+ e.output ?? 0,
737
+ e.reasoning ?? 0,
738
+ e.cacheRead ?? 0,
739
+ e.cacheWrite ?? 0,
740
+ e.cost ?? 0,
741
+ ].map(csvEscape).join(","));
742
+ output = [headers.join(","), ...rows].join("\n") + "\n";
743
+ }
744
+ if (outputFile) {
745
+ writeFileSync(outputFile, output);
746
+ console.log(`\n Exported ${entries.length} entries to ${outputFile}\n`);
747
+ }
748
+ else {
749
+ process.stdout.write(output);
750
+ }
751
+ }
752
+ function csvEscape(v) {
753
+ if (v == null)
754
+ return "";
755
+ const s = String(v);
756
+ if (/[",\n\r]/.test(s)) {
757
+ return `"${s.replace(/"/g, '""')}"`;
758
+ }
759
+ return s;
760
+ }
488
761
  function cmdBudget() {
489
762
  const config = loadConfig();
490
763
  const budget = config.budget;
@@ -584,64 +857,206 @@ function cmdHelp() {
584
857
  (default) Show usage statistics
585
858
  budget Show budget status (daily/weekly/monthly)
586
859
  pricing Show built-in pricing table
587
- models Show your used models and their pricing status
588
- config Show/generate configuration
860
+ models Show your used models and their pricing status
861
+ config Show/generate/modify configuration
862
+ export Export token data to CSV/JSON
863
+ trend Show daily cost/tokens trend chart
589
864
 
590
865
  Statistics Options:
591
866
  today Show today's usage
592
- week Show this week's usage
867
+ week Show this week's usage
593
868
  month Show this month's usage
594
869
  all Show all-time usage (default)
595
-
596
- --by <type> Group by: model, agent, provider, daily, all
870
+
871
+ --by <type> Group by: model, agent, provider, session, daily, all
872
+
873
+ Export Options:
874
+ --format csv (default) or json
875
+ --period today, week, month, all (default)
876
+ --output Write to file instead of stdout
877
+
878
+ Trend Options:
879
+ --days N Number of days to chart (default 30)
880
+ --metric cost (default), tokens, or messages
881
+ --width W Chart width in characters (default 60)
882
+
883
+ Config Sub-commands:
884
+ config show Show current config
885
+ config init Show example config with explanation
886
+ config generate Create config file
887
+ config get <key> Get a config value
888
+ config set <key> <value> Set a config value
889
+ config unset <key> Reset a config value to default
890
+
891
+ Settable config keys:
892
+ budget.daily, budget.weekly, budget.monthly, budget.warnAt
893
+ toast.enabled, toast.duration, toast.showOnIdle
597
894
 
598
895
  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
896
+ opencode-tokens # All-time summary
897
+ opencode-tokens today --by model # Today by model
898
+ opencode-tokens week --by session # This week by session
899
+ opencode-tokens trend --days 7 # 7-day cost trend
900
+ opencode-tokens export --format csv # Export all data as CSV
901
+ opencode-tokens config set budget.daily 10 # Set daily budget to $10
902
+ opencode-tokens config get toast.enabled # Check if toast is enabled
606
903
  `);
607
904
  }
608
905
  // ============================================================================
609
- // Main
906
+ // Trend
610
907
  // ============================================================================
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();
908
+ function cmdTrend(flags) {
909
+ const days = parseInt(String(flagValue(flags, "days") ?? "30"), 10);
910
+ const metric = flagValue(flags, "metric") ?? "cost";
911
+ const width = parseInt(String(flagValue(flags, "width") ?? "60"), 10);
912
+ const since = getStartOfDay(new Date(Date.now() - days * 86400000));
913
+ const entries = loadEntries(since);
914
+ if (entries.length === 0) {
915
+ console.log(`\n (no data in period)\n`);
616
916
  return;
617
917
  }
618
- // Handle subcommands
619
- if (command === "budget") {
620
- cmdBudget();
621
- return;
918
+ // Aggregate by day
919
+ const dayMap = new Map();
920
+ for (const e of entries) {
921
+ const dayStart = getStartOfDay(new Date(e._ts));
922
+ if (!dayMap.has(dayStart)) {
923
+ dayMap.set(dayStart, { cost: 0, tokens: 0, messages: 0 });
924
+ }
925
+ const d = dayMap.get(dayStart);
926
+ d.cost += e.cost ?? 0;
927
+ d.tokens += (e.input ?? 0) + (e.output ?? 0) + (e.reasoning ?? 0);
928
+ d.messages += 1;
622
929
  }
623
- if (command === "pricing") {
624
- cmdPricing();
930
+ const sorted = Array.from(dayMap.entries()).sort(([a], [b]) => a - b);
931
+ if (sorted.length < 2) {
932
+ const only = sorted[0];
933
+ if (only) {
934
+ const v = metric === "tokens" ? formatTokens(only[1].tokens) : metric === "messages" ? String(only[1].messages) : formatCost(only[1].cost);
935
+ console.log(`\n ${new Date(only[0]).toISOString().slice(0, 10)}: ${v}\n`);
936
+ }
625
937
  return;
626
938
  }
627
- if (command === "models") {
628
- cmdModels();
939
+ const values = sorted.map(([, d]) => metric === "tokens" ? d.tokens : metric === "messages" ? d.messages : d.cost);
940
+ const maxVal = Math.max(...values, 1);
941
+ const H = Math.max(5, Math.min(Math.floor(width / 3), 20));
942
+ if (width < 35) {
943
+ // Fallback: simple sparkline
944
+ const chars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
945
+ const spark = values.map(v => chars[Math.min(Math.floor((v / maxVal) * 7), 7)]).join("");
946
+ console.log(`\n ${spark}\n`);
629
947
  return;
630
948
  }
631
- if (command === "config") {
632
- cmdConfig(args[1]);
949
+ // Build chart
950
+ const cols = values.map((v) => ({ value: v, y: Math.round((v / maxVal) * (H - 1)) }));
951
+ const chartWidth = Math.max(width - 12, 20);
952
+ const xStep = Math.max(2, Math.floor(chartWidth / sorted.length));
953
+ const yLabelStep = Math.max(1, Math.floor(H / 5));
954
+ const lines = [];
955
+ for (let row = H - 1; row >= 0; row--) {
956
+ let line = "";
957
+ const valAtRow = (row / (H - 1)) * maxVal;
958
+ const label = row === H - 1 || row === 0 || (H - 1 - row) % yLabelStep === 0
959
+ ? metric === "tokens" ? formatTokens(valAtRow) : metric === "messages" ? String(Math.round(valAtRow)) : formatCost(valAtRow)
960
+ : "";
961
+ line += padLeft(label, 9);
962
+ line += row === 0 ? " ┼" : " ┤";
963
+ for (let ci = 0; ci < cols.length; ci++) {
964
+ const col = cols[ci];
965
+ const nextY = ci < cols.length - 1 ? cols[ci + 1].y : col.y;
966
+ if (col.y === row) {
967
+ if (ci > 0) {
968
+ const prevY = cols[ci - 1].y;
969
+ if (prevY < col.y && nextY <= col.y)
970
+ line += "╭";
971
+ else if (prevY > col.y && nextY >= col.y)
972
+ line += "╰";
973
+ else if (prevY < col.y || nextY < col.y)
974
+ line += "╭";
975
+ else if (prevY > col.y || nextY > col.y)
976
+ line += "╰";
977
+ else
978
+ line += "─";
979
+ }
980
+ else {
981
+ line += nextY > col.y ? "╭" : nextY < col.y ? "╰" : "─";
982
+ }
983
+ if (xStep > 1 && ci < cols.length - 1 && nextY === row) {
984
+ line += "─".repeat(xStep - 1);
985
+ }
986
+ }
987
+ else if (ci > 0 && ci < cols.length) {
988
+ const prevY = cols[ci - 1].y;
989
+ if ((prevY < row && col.y > row) || (prevY > row && col.y < row)) {
990
+ line += prevY < col.y ? "╱" : "╲";
991
+ }
992
+ else {
993
+ line += " ".repeat(xStep > 1 && nextY !== row ? 1 : Math.min(xStep, 1));
994
+ }
995
+ }
996
+ }
997
+ lines.push(line);
998
+ }
999
+ // Bottom axis
1000
+ let axis = " ".repeat(9) + " └";
1001
+ axis += "─".repeat(xStep * cols.length);
1002
+ lines.push(axis);
1003
+ // X axis labels
1004
+ const labelStep = Math.max(1, Math.ceil(sorted.length / 6));
1005
+ let xLabels = " ".repeat(11);
1006
+ for (let i = 0; i < cols.length; i++) {
1007
+ if (i % labelStep === 0 || i === cols.length - 1) {
1008
+ const d = new Date(sorted[i][0]);
1009
+ const ds = `${d.getMonth() + 1}/${d.getDate()}`;
1010
+ xLabels += ds;
1011
+ if (i < cols.length - 1)
1012
+ xLabels += " ".repeat(Math.max(1, xStep - ds.length + 1));
1013
+ }
1014
+ }
1015
+ lines.push(xLabels);
1016
+ console.log();
1017
+ for (const l of lines)
1018
+ console.log(" " + l);
1019
+ console.log();
1020
+ }
1021
+ // ============================================================================
1022
+ // Main
1023
+ // ============================================================================
1024
+ function main() {
1025
+ const args = process.argv.slice(2);
1026
+ const parsed = parseArgs(args);
1027
+ if (parsed.flags.has("help")) {
1028
+ cmdHelp();
633
1029
  return;
634
1030
  }
635
- // Parse stats arguments
1031
+ const { command } = parsed;
1032
+ switch (command) {
1033
+ case "budget":
1034
+ cmdBudget();
1035
+ return;
1036
+ case "pricing":
1037
+ cmdPricing();
1038
+ return;
1039
+ case "models":
1040
+ cmdModels();
1041
+ return;
1042
+ case "config":
1043
+ cmdConfig(parsed.positional);
1044
+ return;
1045
+ case "export":
1046
+ cmdExport(parsed.flags);
1047
+ return;
1048
+ case "trend":
1049
+ cmdTrend(parsed.flags);
1050
+ return;
1051
+ }
1052
+ // Default: stats
636
1053
  let period = "all";
637
1054
  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;
1055
+ breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
1056
+ for (const p of ["today", "week", "month", "all"]) {
1057
+ if (parsed.positional.includes(p)) {
1058
+ period = p;
1059
+ break;
645
1060
  }
646
1061
  }
647
1062
  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.0",
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": [