@anibx/token-tracker 1.0.2
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 +137 -0
- package/bin/launch.cjs +51 -0
- package/package.json +42 -0
- package/scripts/postinstall.cjs +159 -0
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Token Tracker
|
|
2
|
+
|
|
3
|
+
A cross-platform desktop app that shows real-time and historical token usage + costs for **Anthropic (Claude)** and **OpenAI (GPT)** APIs.
|
|
4
|
+
|
|
5
|
+
Built with **Tauri v2** (Rust backend) + **React 19 + TypeScript** (frontend).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g @anibx/token-tracker
|
|
13
|
+
token-tracker
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or download a binary directly from the [latest release](https://github.com/ani0x53/token-tracker/releases/latest).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Real-time polling** — fetches usage from Anthropic and OpenAI APIs on a configurable interval (default 5 min)
|
|
23
|
+
- **30-day history** — stored locally in SQLite, persists across restarts
|
|
24
|
+
- **Daily line chart** — cost over time per provider
|
|
25
|
+
- **Model breakdown bar chart** — cost per model (Claude Sonnet, Opus, GPT-4o, etc.)
|
|
26
|
+
- **Spending alerts** — OS-level notifications when daily/monthly thresholds are exceeded
|
|
27
|
+
- **System tray** — shows today's total cost, click to open window
|
|
28
|
+
- **Dark UI** — Tailwind CSS dark theme
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
### macOS / Windows
|
|
35
|
+
Tauri's prerequisites are automatically handled by the platform toolchain.
|
|
36
|
+
|
|
37
|
+
### Linux (Ubuntu/Debian)
|
|
38
|
+
```bash
|
|
39
|
+
sudo apt-get install -y \
|
|
40
|
+
libwebkit2gtk-4.1-dev \
|
|
41
|
+
libgtk-3-dev \
|
|
42
|
+
libayatana-appindicator3-dev \
|
|
43
|
+
librsvg2-dev \
|
|
44
|
+
pkg-config
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Rust
|
|
48
|
+
```bash
|
|
49
|
+
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|
50
|
+
source "$HOME/.cargo/env"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Setup
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 1. Install JS dependencies
|
|
59
|
+
npm install
|
|
60
|
+
|
|
61
|
+
# 2. Run in development mode
|
|
62
|
+
npm run tauri dev
|
|
63
|
+
|
|
64
|
+
# 3. Build a release binary
|
|
65
|
+
npm run tauri build
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
On first launch the Settings panel opens automatically. Enter your API keys there.
|
|
73
|
+
|
|
74
|
+
**Keys are stored only on your machine** — in your OS app data directory. They are never sent anywhere except the respective API provider.
|
|
75
|
+
|
|
76
|
+
| Setting | Where to get it |
|
|
77
|
+
|---------|----------------|
|
|
78
|
+
| **Anthropic Admin Key** | [console.anthropic.com/settings/admin-keys](https://console.anthropic.com/settings/admin-keys) — must be an **Admin** key, not a regular API key |
|
|
79
|
+
| **OpenAI API Key** | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
|
|
80
|
+
| **Poll Interval** | Seconds between data fetches (default 300) |
|
|
81
|
+
| **Daily Alert ($)** | OS notification when daily spend exceeds this amount |
|
|
82
|
+
| **Monthly Alert ($)** | OS notification when monthly spend exceeds this amount |
|
|
83
|
+
|
|
84
|
+
You can leave out either key if you only use one provider.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Architecture
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
token-tracker/
|
|
92
|
+
├── src/ # React frontend
|
|
93
|
+
│ ├── App.tsx
|
|
94
|
+
│ ├── components/
|
|
95
|
+
│ │ ├── Dashboard.tsx # Main layout
|
|
96
|
+
│ │ ├── ProviderCard.tsx # Per-provider summary card
|
|
97
|
+
│ │ ├── UsageChart.tsx # Line chart — daily usage
|
|
98
|
+
│ │ ├── ModelBreakdown.tsx # Bar chart — cost per model
|
|
99
|
+
│ │ └── AlertSettings.tsx # Settings modal
|
|
100
|
+
│ ├── hooks/
|
|
101
|
+
│ │ ├── useUsageData.ts # SQLite query + event listeners
|
|
102
|
+
│ │ └── useAlerts.ts # Spending alert logic
|
|
103
|
+
│ └── store/
|
|
104
|
+
│ └── settingsStore.ts # Zustand store for settings
|
|
105
|
+
├── src-tauri/
|
|
106
|
+
│ └── src/
|
|
107
|
+
│ ├── lib.rs # Tauri commands + app setup
|
|
108
|
+
│ ├── api/
|
|
109
|
+
│ │ ├── anthropic.rs # Anthropic usage API client
|
|
110
|
+
│ │ └── openai.rs # OpenAI usage API client
|
|
111
|
+
│ ├── poller.rs # Background polling loop
|
|
112
|
+
│ ├── storage.rs # SQLite schema constants
|
|
113
|
+
│ └── tray.rs # System tray setup
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Data flow
|
|
117
|
+
|
|
118
|
+
1. Rust `poller` fetches both APIs every N seconds
|
|
119
|
+
2. New snapshots are emitted as `new-snapshots` Tauri events
|
|
120
|
+
3. Frontend `useUsageData` hook receives events and upserts into local SQLite
|
|
121
|
+
4. React Query re-fetches from DB and re-renders charts
|
|
122
|
+
5. Tray tooltip is updated with today's total cost
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Tech Stack
|
|
127
|
+
|
|
128
|
+
| Layer | Choice |
|
|
129
|
+
|-------|--------|
|
|
130
|
+
| Desktop shell | Tauri v2 |
|
|
131
|
+
| Frontend | React 19 + TypeScript |
|
|
132
|
+
| Styling | Tailwind CSS v4 |
|
|
133
|
+
| Charts | Recharts |
|
|
134
|
+
| State | Zustand |
|
|
135
|
+
| DB | SQLite (tauri-plugin-sql) |
|
|
136
|
+
| HTTP | reqwest (Rust) |
|
|
137
|
+
| Notifications | tauri-plugin-notification |
|
package/bin/launch.cjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawn, execFileSync } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
9
|
+
function launch() {
|
|
10
|
+
const p = process.platform;
|
|
11
|
+
|
|
12
|
+
if (p === 'linux') {
|
|
13
|
+
const bin = path.join(os.homedir(), '.local', 'share', 'token-tracker', 'token-tracker.AppImage');
|
|
14
|
+
if (!fs.existsSync(bin)) {
|
|
15
|
+
console.error(`Binary not found at ${bin}`);
|
|
16
|
+
console.error('Try reinstalling: npm install -g @anibx/token-tracker');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
spawn(bin, [], { detached: true, stdio: 'ignore' }).unref();
|
|
20
|
+
|
|
21
|
+
} else if (p === 'darwin') {
|
|
22
|
+
const local = path.join(os.homedir(), 'Applications', 'Token Tracker.app');
|
|
23
|
+
const system = '/Applications/Token Tracker.app';
|
|
24
|
+
if (!fs.existsSync(local) && !fs.existsSync(system)) {
|
|
25
|
+
console.error('Token Tracker.app not found in ~/Applications or /Applications.');
|
|
26
|
+
console.error('Try reinstalling: npm install -g @anibx/token-tracker');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const appPath = fs.existsSync(local) ? local : system;
|
|
30
|
+
execFileSync('open', [appPath]);
|
|
31
|
+
|
|
32
|
+
} else if (p === 'win32') {
|
|
33
|
+
const exe = path.join(
|
|
34
|
+
process.env['ProgramFiles'] || 'C:\\Program Files',
|
|
35
|
+
'Token Tracker',
|
|
36
|
+
'Token Tracker.exe'
|
|
37
|
+
);
|
|
38
|
+
if (!fs.existsSync(exe)) {
|
|
39
|
+
console.error(`Executable not found at ${exe}`);
|
|
40
|
+
console.error('Try reinstalling: npm install -g @anibx/token-tracker');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
spawn(exe, [], { detached: true, stdio: 'ignore' }).unref();
|
|
44
|
+
|
|
45
|
+
} else {
|
|
46
|
+
console.error(`Unsupported platform: ${p}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
launch();
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anibx/token-tracker",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Real-time token usage and cost tracker for Anthropic and OpenAI APIs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"token-tracker": "./bin/launch.cjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"scripts/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "vite",
|
|
15
|
+
"build": "tsc && vite build",
|
|
16
|
+
"preview": "vite preview",
|
|
17
|
+
"tauri": "tauri",
|
|
18
|
+
"postinstall": "node scripts/postinstall.cjs"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@tanstack/react-query": "^5.90.21",
|
|
22
|
+
"@tauri-apps/api": "^2",
|
|
23
|
+
"@tauri-apps/plugin-notification": "^2.3.3",
|
|
24
|
+
"@tauri-apps/plugin-opener": "^2",
|
|
25
|
+
"@tauri-apps/plugin-sql": "^2.3.2",
|
|
26
|
+
"lucide-react": "^0.575.0",
|
|
27
|
+
"react": "^19.1.0",
|
|
28
|
+
"react-dom": "^19.1.0",
|
|
29
|
+
"recharts": "^3.7.0",
|
|
30
|
+
"zustand": "^5.0.11"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
34
|
+
"@tauri-apps/cli": "^2",
|
|
35
|
+
"@types/react": "^19.1.8",
|
|
36
|
+
"@types/react-dom": "^19.1.6",
|
|
37
|
+
"@vitejs/plugin-react": "^4.6.0",
|
|
38
|
+
"tailwindcss": "^4.2.1",
|
|
39
|
+
"typescript": "~5.8.3",
|
|
40
|
+
"vite": "^7.0.4"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
// Skip when running inside the source repository
|
|
11
|
+
if (fs.existsSync(path.join(__dirname, '..', 'src', 'App.tsx'))) {
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Skip if explicitly disabled (e.g. in CI during npm publish)
|
|
16
|
+
if (process.env.SKIP_POSTINSTALL) {
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const REPO = 'ani0x53/token-tracker';
|
|
21
|
+
const { version } = require('../package.json');
|
|
22
|
+
|
|
23
|
+
function getInstallDir() {
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
return path.join(process.env.APPDATA || os.homedir(), 'token-tracker');
|
|
26
|
+
}
|
|
27
|
+
return path.join(os.homedir(), '.local', 'share', 'token-tracker');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function findAsset(assets) {
|
|
31
|
+
const p = process.platform;
|
|
32
|
+
const a = process.arch;
|
|
33
|
+
|
|
34
|
+
return assets.find(({ name }) => {
|
|
35
|
+
if (p === 'linux') {
|
|
36
|
+
if (!name.endsWith('.AppImage')) return false;
|
|
37
|
+
return a === 'arm64' ? name.includes('aarch64') : name.includes('amd64');
|
|
38
|
+
}
|
|
39
|
+
if (p === 'darwin') {
|
|
40
|
+
if (!name.endsWith('.dmg')) return false;
|
|
41
|
+
// universal dmg works for both arches; also match arch-specific ones
|
|
42
|
+
if (name.includes('universal')) return true;
|
|
43
|
+
return a === 'arm64' ? name.includes('aarch64') : name.includes('x64') && !name.includes('aarch64');
|
|
44
|
+
}
|
|
45
|
+
if (p === 'win32') {
|
|
46
|
+
return name.endsWith('-setup.exe') && name.includes('x64');
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function get(url) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
https.get(url, { headers: { 'User-Agent': 'token-tracker-installer' } }, (res) => {
|
|
55
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
56
|
+
resolve(get(res.headers.location));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let body = '';
|
|
60
|
+
res.on('data', (chunk) => (body += chunk));
|
|
61
|
+
res.on('end', () => resolve(body));
|
|
62
|
+
res.on('error', reject);
|
|
63
|
+
}).on('error', reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function download(url, dest) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
function fetch(url) {
|
|
70
|
+
https.get(url, { headers: { 'User-Agent': 'token-tracker-installer' } }, (res) => {
|
|
71
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
72
|
+
fetch(res.headers.location);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (res.statusCode !== 200) {
|
|
76
|
+
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const total = parseInt(res.headers['content-length'] || '0', 10);
|
|
80
|
+
let received = 0;
|
|
81
|
+
const file = fs.createWriteStream(dest);
|
|
82
|
+
|
|
83
|
+
res.on('data', (chunk) => {
|
|
84
|
+
received += chunk.length;
|
|
85
|
+
if (total) {
|
|
86
|
+
const pct = Math.round((received / total) * 100);
|
|
87
|
+
process.stdout.write(`\r Downloading... ${pct}%`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
res.pipe(file);
|
|
91
|
+
file.on('finish', () => {
|
|
92
|
+
process.stdout.write('\n');
|
|
93
|
+
resolve();
|
|
94
|
+
});
|
|
95
|
+
file.on('error', reject);
|
|
96
|
+
}).on('error', reject);
|
|
97
|
+
}
|
|
98
|
+
fetch(url);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function main() {
|
|
103
|
+
console.log(`\nToken Tracker v${version} — fetching release info...`);
|
|
104
|
+
|
|
105
|
+
let release;
|
|
106
|
+
try {
|
|
107
|
+
const body = await get(`https://api.github.com/repos/${REPO}/releases/latest`);
|
|
108
|
+
release = JSON.parse(body);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error('Could not fetch release info:', e.message);
|
|
111
|
+
console.error(`Download manually: https://github.com/${REPO}/releases`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const asset = findAsset(release.assets || []);
|
|
116
|
+
if (!asset) {
|
|
117
|
+
const names = (release.assets || []).map((a) => a.name).join(', ') || 'none';
|
|
118
|
+
console.error(`No binary found for ${process.platform}/${process.arch}.`);
|
|
119
|
+
console.error(`Available: ${names}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const installDir = getInstallDir();
|
|
124
|
+
fs.mkdirSync(installDir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
const ext = path.extname(asset.name);
|
|
127
|
+
const tmp = path.join(installDir, `download${ext}`);
|
|
128
|
+
|
|
129
|
+
console.log(`Downloading ${asset.name}...`);
|
|
130
|
+
await download(asset.browser_download_url, tmp);
|
|
131
|
+
|
|
132
|
+
if (process.platform === 'linux') {
|
|
133
|
+
const dest = path.join(installDir, 'token-tracker.AppImage');
|
|
134
|
+
fs.renameSync(tmp, dest);
|
|
135
|
+
fs.chmodSync(dest, 0o755);
|
|
136
|
+
console.log(`Installed to ${dest}`);
|
|
137
|
+
} else if (process.platform === 'darwin') {
|
|
138
|
+
console.log('Mounting disk image...');
|
|
139
|
+
execFileSync('hdiutil', ['attach', tmp, '-quiet', '-nobrowse']);
|
|
140
|
+
const appsDir = path.join(os.homedir(), 'Applications');
|
|
141
|
+
fs.mkdirSync(appsDir, { recursive: true });
|
|
142
|
+
execFileSync('cp', ['-r', '/Volumes/Token Tracker/Token Tracker.app', appsDir]);
|
|
143
|
+
execFileSync('hdiutil', ['detach', '/Volumes/Token Tracker', '-quiet']);
|
|
144
|
+
fs.unlinkSync(tmp);
|
|
145
|
+
console.log(`Installed to ~/Applications/Token Tracker.app`);
|
|
146
|
+
} else if (process.platform === 'win32') {
|
|
147
|
+
console.log('Running installer (silent)...');
|
|
148
|
+
execFileSync(tmp, ['/S'], { windowsHide: true });
|
|
149
|
+
fs.unlinkSync(tmp);
|
|
150
|
+
console.log('Token Tracker installed.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log('Done! Run: token-tracker\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
main().catch((e) => {
|
|
157
|
+
console.error('\nInstallation failed:', e.message);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
});
|