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 +68 -0
- package/dist/bin/opencode-tokens.js +454 -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,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
|
|
485
|
-
console.log(` opencode-tokens config
|
|
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
|
|
600
|
-
opencode-tokens
|
|
601
|
-
opencode-tokens
|
|
602
|
-
opencode-tokens
|
|
603
|
-
opencode-tokens
|
|
604
|
-
opencode-tokens
|
|
605
|
-
opencode-tokens 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
|
-
//
|
|
905
|
+
// Trend
|
|
610
906
|
// ============================================================================
|
|
611
|
-
function
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
if (
|
|
641
|
-
|
|
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.
|
|
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": [
|