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 +68 -0
- package/dist/bin/opencode-tokens.js +455 -40
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +55 -0
- package/package.json +2 -2
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(
|
|
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
|
|
485
|
-
console.log(` opencode-tokens config
|
|
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
|
|
600
|
-
opencode-tokens
|
|
601
|
-
opencode-tokens
|
|
602
|
-
opencode-tokens
|
|
603
|
-
opencode-tokens
|
|
604
|
-
opencode-tokens
|
|
605
|
-
opencode-tokens 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
|
-
//
|
|
906
|
+
// Trend
|
|
610
907
|
// ============================================================================
|
|
611
|
-
function
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
if (
|
|
641
|
-
|
|
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.
|
|
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": [
|