@vibe-cafe/vibe-usage 0.7.8 → 0.7.11
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 +2 -1
- package/package.json +1 -1
- package/src/api.js +13 -6
- package/src/parsers/cursor.js +242 -0
- package/src/parsers/index.js +2 -0
- package/src/tools.js +18 -0
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
49
49
|
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only) |
|
|
50
50
|
| Codex CLI | `~/.codex/sessions/` |
|
|
51
51
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
52
|
+
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`) |
|
|
52
53
|
| Gemini CLI | `~/.gemini/tmp/` |
|
|
53
54
|
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
|
|
54
55
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments) |
|
|
@@ -64,7 +65,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
64
65
|
- Parses local session logs from each AI coding tool
|
|
65
66
|
- Aggregates token usage into 30-minute buckets
|
|
66
67
|
- Extracts session metadata from all parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
|
|
67
|
-
- Uploads buckets + sessions to your vibecafe.ai dashboard
|
|
68
|
+
- Uploads buckets + sessions to your vibecafe.ai dashboard (gzip-compressed when ≥ 1 KB, ~94% smaller)
|
|
68
69
|
- Stateless: computes full totals from local logs each sync (idempotent, no state files)
|
|
69
70
|
- For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
|
|
70
71
|
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import https from 'node:https';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
|
+
import { gzipSync } from 'node:zlib';
|
|
4
5
|
|
|
5
6
|
const MAX_RETRIES = 3;
|
|
6
7
|
const INITIAL_DELAY = 1000;
|
|
8
|
+
const GZIP_MIN_BYTES = 1024;
|
|
7
9
|
|
|
8
10
|
export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
9
11
|
let lastError;
|
|
@@ -30,18 +32,23 @@ function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
|
|
|
30
32
|
const url = new URL('/api/usage/ingest', apiUrl);
|
|
31
33
|
const payload = { buckets };
|
|
32
34
|
if (sessions && sessions.length > 0) payload.sessions = sessions;
|
|
33
|
-
const
|
|
35
|
+
const raw = Buffer.from(JSON.stringify(payload));
|
|
36
|
+
const useGzip = raw.length >= GZIP_MIN_BYTES;
|
|
37
|
+
const body = useGzip ? gzipSync(raw) : raw;
|
|
34
38
|
const totalBytes = body.length;
|
|
35
39
|
const mod = url.protocol === 'https:' ? https : http;
|
|
36
40
|
|
|
41
|
+
const headers = {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
44
|
+
'Content-Length': totalBytes,
|
|
45
|
+
};
|
|
46
|
+
if (useGzip) headers['Content-Encoding'] = 'gzip';
|
|
47
|
+
|
|
37
48
|
const req = mod.request(url, {
|
|
38
49
|
method: 'POST',
|
|
39
50
|
timeout: 60_000,
|
|
40
|
-
headers
|
|
41
|
-
'Content-Type': 'application/json',
|
|
42
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
43
|
-
'Content-Length': totalBytes,
|
|
44
|
-
},
|
|
51
|
+
headers,
|
|
45
52
|
}, (res) => {
|
|
46
53
|
let data = '';
|
|
47
54
|
res.on('data', (chunk) => { data += chunk; });
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { aggregateToBuckets } from './index.js';
|
|
6
|
+
|
|
7
|
+
const STATE_DB_RELATIVE = join('User', 'globalStorage', 'state.vscdb');
|
|
8
|
+
const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken';
|
|
9
|
+
const SESSION_COOKIE = 'WorkosCursorSessionToken';
|
|
10
|
+
|
|
11
|
+
function getDefaultStateDbPath() {
|
|
12
|
+
if (process.platform === 'darwin') {
|
|
13
|
+
return join(homedir(), 'Library', 'Application Support', 'Cursor', STATE_DB_RELATIVE);
|
|
14
|
+
}
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
17
|
+
return join(appData, 'Cursor', STATE_DB_RELATIVE);
|
|
18
|
+
}
|
|
19
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
20
|
+
return join(xdgConfigHome, 'Cursor', STATE_DB_RELATIVE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getCursorStateDbPath() {
|
|
24
|
+
const explicit = process.env.CURSOR_STATE_DB_PATH?.trim();
|
|
25
|
+
if (explicit) {
|
|
26
|
+
const resolved = resolve(explicit);
|
|
27
|
+
return existsSync(resolved) ? resolved : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const configDirs = process.env.CURSOR_CONFIG_DIR?.trim();
|
|
31
|
+
const candidates = configDirs
|
|
32
|
+
? configDirs.split(',').map(v => v.trim()).filter(Boolean).map(v => {
|
|
33
|
+
const r = resolve(v);
|
|
34
|
+
return r.endsWith('.vscdb') ? r : join(r, STATE_DB_RELATIVE);
|
|
35
|
+
})
|
|
36
|
+
: [getDefaultStateDbPath()];
|
|
37
|
+
|
|
38
|
+
for (const c of candidates) {
|
|
39
|
+
if (existsSync(c)) return c;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readAccessToken(dbPath) {
|
|
45
|
+
let snapshotDir = null;
|
|
46
|
+
let queryPath = dbPath;
|
|
47
|
+
try {
|
|
48
|
+
return queryAccessToken(queryPath);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Cursor app holds a write lock; copy WAL set to a temp dir and retry
|
|
51
|
+
if (!isLockError(err)) throw err;
|
|
52
|
+
snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-cursor-'));
|
|
53
|
+
queryPath = join(snapshotDir, 'state.vscdb');
|
|
54
|
+
copyFileSync(dbPath, queryPath);
|
|
55
|
+
for (const suffix of ['-shm', '-wal']) {
|
|
56
|
+
const companion = `${dbPath}${suffix}`;
|
|
57
|
+
if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return queryAccessToken(queryPath);
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(snapshotDir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function queryAccessToken(dbPath) {
|
|
68
|
+
const sql = `SELECT value FROM ItemTable WHERE key = '${ACCESS_TOKEN_KEY}' LIMIT 1`;
|
|
69
|
+
const out = execFileSync('sqlite3', ['-json', dbPath, sql], {
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
72
|
+
timeout: 15000,
|
|
73
|
+
});
|
|
74
|
+
const trimmed = out.trim();
|
|
75
|
+
if (!trimmed || trimmed === '[]') return null;
|
|
76
|
+
const rows = JSON.parse(trimmed);
|
|
77
|
+
const value = rows[0]?.value;
|
|
78
|
+
if (typeof value !== 'string') return null;
|
|
79
|
+
const t = value.trim();
|
|
80
|
+
return t || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isLockError(err) {
|
|
84
|
+
return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function decodeJwtSub(token) {
|
|
88
|
+
const payload = token.split('.')[1];
|
|
89
|
+
if (!payload) return null;
|
|
90
|
+
try {
|
|
91
|
+
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
92
|
+
const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, '=');
|
|
93
|
+
const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'));
|
|
94
|
+
return typeof json.sub === 'string' ? json.sub.trim() : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
101
|
+
|
|
102
|
+
async function fetchUsageCsv(token) {
|
|
103
|
+
const url = `${(process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '')}/api/dashboard/export-usage-events-csv?strategy=tokens`;
|
|
104
|
+
const sub = decodeJwtSub(token);
|
|
105
|
+
const cookieValues = sub ? [token, `${sub}::${token}`] : [token];
|
|
106
|
+
|
|
107
|
+
const attempts = [{ Authorization: `Bearer ${token}` }];
|
|
108
|
+
for (const cv of cookieValues) {
|
|
109
|
+
attempts.push({ Cookie: `${SESSION_COOKIE}=${cv}` });
|
|
110
|
+
attempts.push({ Authorization: `Bearer ${token}`, Cookie: `${SESSION_COOKIE}=${cv}` });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const failures = [];
|
|
114
|
+
for (const headers of attempts) {
|
|
115
|
+
let resp;
|
|
116
|
+
try {
|
|
117
|
+
resp = await fetch(url, {
|
|
118
|
+
headers: { Accept: 'text/csv,*/*;q=0.8', ...headers },
|
|
119
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
120
|
+
});
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// Hard-fail on network/timeout: stop trying further headers (won't fix
|
|
123
|
+
// a downed host) and signal a soft skip to the caller.
|
|
124
|
+
const reason = e.name === 'TimeoutError' ? 'timeout' : `network: ${e.message}`;
|
|
125
|
+
const err = new Error(`Cursor usage export skipped (${reason})`);
|
|
126
|
+
err.skip = true;
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
if (resp.ok) return await resp.text();
|
|
130
|
+
failures.push(`${resp.status} ${resp.statusText}`);
|
|
131
|
+
}
|
|
132
|
+
// All auth combos rejected — token is likely expired. Surface to user.
|
|
133
|
+
throw new Error(`Cursor usage export auth failed (${failures.join('; ')})`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseCsv(text) {
|
|
137
|
+
const rows = [];
|
|
138
|
+
let field = '';
|
|
139
|
+
let row = [];
|
|
140
|
+
let inQuotes = false;
|
|
141
|
+
let i = 0;
|
|
142
|
+
while (i < text.length) {
|
|
143
|
+
const c = text[i];
|
|
144
|
+
if (inQuotes) {
|
|
145
|
+
if (c === '"') {
|
|
146
|
+
if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
|
|
147
|
+
inQuotes = false; i++; continue;
|
|
148
|
+
}
|
|
149
|
+
field += c; i++; continue;
|
|
150
|
+
}
|
|
151
|
+
if (c === '"') { inQuotes = true; i++; continue; }
|
|
152
|
+
if (c === ',') { row.push(field); field = ''; i++; continue; }
|
|
153
|
+
if (c === '\r') { i++; continue; }
|
|
154
|
+
if (c === '\n') { row.push(field); rows.push(row); field = ''; row = []; i++; continue; }
|
|
155
|
+
field += c; i++;
|
|
156
|
+
}
|
|
157
|
+
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseDate(value) {
|
|
162
|
+
if (!value) return null;
|
|
163
|
+
const t = String(value).trim();
|
|
164
|
+
if (!t) return null;
|
|
165
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return new Date(`${t}T00:00:00Z`);
|
|
166
|
+
const d = new Date(t);
|
|
167
|
+
return isNaN(d.getTime()) ? null : d;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseInt0(value) {
|
|
171
|
+
if (value == null) return 0;
|
|
172
|
+
const n = Number(String(value).replace(/,/g, '').trim());
|
|
173
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function parse() {
|
|
177
|
+
const dbPath = getCursorStateDbPath();
|
|
178
|
+
if (!dbPath) return { buckets: [], sessions: [] };
|
|
179
|
+
|
|
180
|
+
let token;
|
|
181
|
+
try {
|
|
182
|
+
token = readAccessToken(dbPath);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
|
|
185
|
+
throw new Error('sqlite3 CLI not found. Install sqlite3 to sync Cursor data.');
|
|
186
|
+
}
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
if (!token) return { buckets: [], sessions: [] };
|
|
190
|
+
|
|
191
|
+
let csv;
|
|
192
|
+
try {
|
|
193
|
+
csv = await fetchUsageCsv(token);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
// Network/timeout → silent skip (avoid noisy daemon logs every 5 min).
|
|
196
|
+
// Auth failure → bubble up so user sees they need to re-login in Cursor.
|
|
197
|
+
if (err && err.skip) return { buckets: [], sessions: [] };
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
const rows = parseCsv(csv);
|
|
201
|
+
if (rows.length < 2) return { buckets: [], sessions: [] };
|
|
202
|
+
|
|
203
|
+
const header = rows[0].map(h => h.trim());
|
|
204
|
+
const idx = (name) => header.indexOf(name);
|
|
205
|
+
const dateIdx = idx('Date');
|
|
206
|
+
const modelIdx = idx('Model');
|
|
207
|
+
const inputCacheWriteIdx = idx('Input (w/ Cache Write)');
|
|
208
|
+
const inputNoCacheIdx = idx('Input (w/o Cache Write)');
|
|
209
|
+
const cacheReadIdx = idx('Cache Read');
|
|
210
|
+
const outputIdx = idx('Output Tokens');
|
|
211
|
+
|
|
212
|
+
if (dateIdx < 0 || modelIdx < 0) return { buckets: [], sessions: [] };
|
|
213
|
+
|
|
214
|
+
const entries = [];
|
|
215
|
+
for (let r = 1; r < rows.length; r++) {
|
|
216
|
+
const row = rows[r];
|
|
217
|
+
if (row.length === 1 && row[0].trim() === '') continue;
|
|
218
|
+
const timestamp = parseDate(row[dateIdx]);
|
|
219
|
+
const model = row[modelIdx]?.trim();
|
|
220
|
+
if (!timestamp || !model) continue;
|
|
221
|
+
|
|
222
|
+
const inputCacheWrite = inputCacheWriteIdx >= 0 ? parseInt0(row[inputCacheWriteIdx]) : 0;
|
|
223
|
+
const inputNoCache = inputNoCacheIdx >= 0 ? parseInt0(row[inputNoCacheIdx]) : 0;
|
|
224
|
+
const cacheRead = cacheReadIdx >= 0 ? parseInt0(row[cacheReadIdx]) : 0;
|
|
225
|
+
const output = outputIdx >= 0 ? parseInt0(row[outputIdx]) : 0;
|
|
226
|
+
|
|
227
|
+
if (inputCacheWrite + inputNoCache + cacheRead + output === 0) continue;
|
|
228
|
+
|
|
229
|
+
entries.push({
|
|
230
|
+
source: 'cursor',
|
|
231
|
+
model,
|
|
232
|
+
project: 'unknown',
|
|
233
|
+
timestamp,
|
|
234
|
+
inputTokens: inputCacheWrite + inputNoCache,
|
|
235
|
+
outputTokens: output,
|
|
236
|
+
cachedInputTokens: cacheRead,
|
|
237
|
+
reasoningOutputTokens: 0,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { buckets: aggregateToBuckets(entries), sessions: [] };
|
|
242
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { parse as parseClaudeCode } from './claude-code.js';
|
|
3
3
|
import { parse as parseCodex } from './codex.js';
|
|
4
4
|
import { parse as parseCopilotCli } from './copilot-cli.js';
|
|
5
|
+
import { parse as parseCursor } from './cursor.js';
|
|
5
6
|
import { parse as parseGeminiCli } from './gemini-cli.js';
|
|
6
7
|
import { parse as parseOpencode } from './opencode.js';
|
|
7
8
|
import { parse as parseOpenclaw } from './openclaw.js';
|
|
@@ -17,6 +18,7 @@ export const parsers = {
|
|
|
17
18
|
'claude-code': parseClaudeCode,
|
|
18
19
|
'codex': parseCodex,
|
|
19
20
|
'copilot-cli': parseCopilotCli,
|
|
21
|
+
'cursor': parseCursor,
|
|
20
22
|
'gemini-cli': parseGeminiCli,
|
|
21
23
|
'opencode': parseOpencode,
|
|
22
24
|
'openclaw': parseOpenclaw,
|
package/src/tools.js
CHANGED
|
@@ -2,6 +2,19 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
|
+
function getCursorStateDbPath() {
|
|
6
|
+
const rel = join('User', 'globalStorage', 'state.vscdb');
|
|
7
|
+
if (process.platform === 'darwin') {
|
|
8
|
+
return join(homedir(), 'Library', 'Application Support', 'Cursor', rel);
|
|
9
|
+
}
|
|
10
|
+
if (process.platform === 'win32') {
|
|
11
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
12
|
+
return join(appData, 'Cursor', rel);
|
|
13
|
+
}
|
|
14
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
15
|
+
return join(xdgConfigHome, 'Cursor', rel);
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/** Find all OpenClaw data roots: ~/.openclaw and ~/.openclaw-<profile> */
|
|
6
19
|
function findOpenclawDataDirs() {
|
|
7
20
|
const home = homedir();
|
|
@@ -41,6 +54,11 @@ export const TOOLS = [
|
|
|
41
54
|
id: 'copilot-cli',
|
|
42
55
|
dataDir: join(homedir(), '.copilot', 'session-state'),
|
|
43
56
|
},
|
|
57
|
+
{
|
|
58
|
+
name: 'Cursor',
|
|
59
|
+
id: 'cursor',
|
|
60
|
+
dataDir: getCursorStateDbPath(),
|
|
61
|
+
},
|
|
44
62
|
{
|
|
45
63
|
name: 'Gemini CLI',
|
|
46
64
|
id: 'gemini-cli',
|