@tokscale/cli 1.0.6 → 1.0.8
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/dist/cli.d.ts +1 -1
- package/dist/cli.js +8 -3
- package/dist/cli.js.map +1 -1
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +1 -114
- package/dist/native.js.map +1 -1
- package/dist/tui/components/Header.js +1 -1
- package/dist/tui/components/Header.js.map +1 -1
- package/package.json +5 -10
- package/src/auth.ts +211 -0
- package/src/cli.ts +1042 -0
- package/src/credentials.ts +123 -0
- package/src/cursor.ts +558 -0
- package/src/graph-types.ts +188 -0
- package/src/graph.ts +485 -0
- package/src/native-runner.ts +105 -0
- package/src/native.ts +807 -0
- package/src/pricing.ts +309 -0
- package/src/sessions/claudecode.ts +119 -0
- package/src/sessions/codex.ts +227 -0
- package/src/sessions/gemini.ts +108 -0
- package/src/sessions/index.ts +126 -0
- package/src/sessions/opencode.ts +94 -0
- package/src/sessions/reports.ts +475 -0
- package/src/sessions/types.ts +59 -0
- package/src/spinner.ts +283 -0
- package/src/submit.ts +175 -0
- package/src/table.ts +233 -0
- package/src/tui/components/Header.tsx +1 -1
- package/src/types.d.ts +28 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokscale CLI Credentials Manager
|
|
3
|
+
* Stores and retrieves API tokens for authenticated CLI operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
|
|
10
|
+
export interface Credentials {
|
|
11
|
+
token: string;
|
|
12
|
+
username: string;
|
|
13
|
+
avatarUrl?: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const OLD_CONFIG_DIR = path.join(os.homedir(), ".tokscale");
|
|
18
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "tokscale");
|
|
19
|
+
const OLD_CREDENTIALS_FILE = path.join(OLD_CONFIG_DIR, "credentials.json");
|
|
20
|
+
const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ensure the config directory exists
|
|
24
|
+
*/
|
|
25
|
+
function ensureConfigDir(): void {
|
|
26
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
27
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Migrate credentials from old path (~/.token-tracker) to new XDG path (~/.config/token-tracker)
|
|
33
|
+
*/
|
|
34
|
+
function migrateFromOldPath(): void {
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.existsSync(CREDENTIALS_FILE) && fs.existsSync(OLD_CREDENTIALS_FILE)) {
|
|
37
|
+
ensureConfigDir();
|
|
38
|
+
fs.copyFileSync(OLD_CREDENTIALS_FILE, CREDENTIALS_FILE);
|
|
39
|
+
fs.chmodSync(CREDENTIALS_FILE, 0o600);
|
|
40
|
+
// Delete old file after successful migration
|
|
41
|
+
fs.unlinkSync(OLD_CREDENTIALS_FILE);
|
|
42
|
+
// Try to remove old directory if empty
|
|
43
|
+
try {
|
|
44
|
+
fs.rmdirSync(OLD_CONFIG_DIR);
|
|
45
|
+
} catch {
|
|
46
|
+
// Directory not empty (cursor files may exist) - ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Migration failed - continue with normal operation (old path may still work)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Save credentials to disk
|
|
56
|
+
*/
|
|
57
|
+
export function saveCredentials(credentials: Credentials): void {
|
|
58
|
+
ensureConfigDir();
|
|
59
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
|
|
60
|
+
encoding: "utf-8",
|
|
61
|
+
mode: 0o600, // Read/write for owner only
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load credentials from disk
|
|
67
|
+
* Returns null if no credentials are stored or file is invalid
|
|
68
|
+
*/
|
|
69
|
+
export function loadCredentials(): Credentials | null {
|
|
70
|
+
migrateFromOldPath();
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const data = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
76
|
+
const parsed = JSON.parse(data);
|
|
77
|
+
|
|
78
|
+
// Validate structure
|
|
79
|
+
if (!parsed.token || !parsed.username) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parsed as Credentials;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Clear stored credentials
|
|
91
|
+
*/
|
|
92
|
+
export function clearCredentials(): boolean {
|
|
93
|
+
try {
|
|
94
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
95
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if user is logged in
|
|
106
|
+
*/
|
|
107
|
+
export function isLoggedIn(): boolean {
|
|
108
|
+
return loadCredentials() !== null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the API base URL
|
|
113
|
+
*/
|
|
114
|
+
export function getApiBaseUrl(): string {
|
|
115
|
+
return process.env.TOKSCALE_API_URL || "https://tokscale.ai";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the credentials file path (for debugging)
|
|
120
|
+
*/
|
|
121
|
+
export function getCredentialsPath(): string {
|
|
122
|
+
return CREDENTIALS_FILE;
|
|
123
|
+
}
|
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor IDE API Client
|
|
3
|
+
* Fetches usage data from Cursor's dashboard API via CSV export
|
|
4
|
+
*
|
|
5
|
+
* API Endpoint: https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens
|
|
6
|
+
* Authentication: WorkosCursorSessionToken cookie
|
|
7
|
+
*
|
|
8
|
+
* CSV Format:
|
|
9
|
+
* Date,Model,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost,Cost to you
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import { parse as parseCsv } from "csv-parse/sync";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface CursorCredentials {
|
|
22
|
+
sessionToken: string;
|
|
23
|
+
userId?: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
expiresAt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CursorUsageRow {
|
|
29
|
+
date: string; // YYYY-MM-DD
|
|
30
|
+
timestamp: number; // Unix milliseconds
|
|
31
|
+
model: string;
|
|
32
|
+
inputWithCacheWrite: number;
|
|
33
|
+
inputWithoutCacheWrite: number;
|
|
34
|
+
cacheRead: number;
|
|
35
|
+
outputTokens: number;
|
|
36
|
+
totalTokens: number;
|
|
37
|
+
apiCost: number; // in USD
|
|
38
|
+
costToYou: number; // in USD
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CursorUsageData {
|
|
42
|
+
source: "cursor";
|
|
43
|
+
model: string;
|
|
44
|
+
providerId: string;
|
|
45
|
+
messageCount: number;
|
|
46
|
+
input: number;
|
|
47
|
+
output: number;
|
|
48
|
+
cacheRead: number;
|
|
49
|
+
cacheWrite: number;
|
|
50
|
+
reasoning: number;
|
|
51
|
+
cost: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CursorMessageWithTimestamp {
|
|
55
|
+
source: "cursor";
|
|
56
|
+
model: string;
|
|
57
|
+
providerId: string;
|
|
58
|
+
timestamp: number;
|
|
59
|
+
input: number;
|
|
60
|
+
output: number;
|
|
61
|
+
cacheRead: number;
|
|
62
|
+
cacheWrite: number;
|
|
63
|
+
reasoning: number;
|
|
64
|
+
cost: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// Credential Management
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
const OLD_CONFIG_DIR = path.join(os.homedir(), ".tokscale");
|
|
72
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "tokscale");
|
|
73
|
+
const OLD_CURSOR_CREDENTIALS_FILE = path.join(OLD_CONFIG_DIR, "cursor-credentials.json");
|
|
74
|
+
const CURSOR_CREDENTIALS_FILE = path.join(CONFIG_DIR, "cursor-credentials.json");
|
|
75
|
+
|
|
76
|
+
function ensureConfigDir(): void {
|
|
77
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
78
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Migrate Cursor credentials and cache from old path to new XDG path
|
|
84
|
+
*/
|
|
85
|
+
function migrateCursorFromOldPath(): void {
|
|
86
|
+
try {
|
|
87
|
+
// Migrate cursor credentials
|
|
88
|
+
if (!fs.existsSync(CURSOR_CREDENTIALS_FILE) && fs.existsSync(OLD_CURSOR_CREDENTIALS_FILE)) {
|
|
89
|
+
ensureConfigDir();
|
|
90
|
+
fs.copyFileSync(OLD_CURSOR_CREDENTIALS_FILE, CURSOR_CREDENTIALS_FILE);
|
|
91
|
+
fs.chmodSync(CURSOR_CREDENTIALS_FILE, 0o600);
|
|
92
|
+
fs.unlinkSync(OLD_CURSOR_CREDENTIALS_FILE);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Migrate cache directory (handled after CURSOR_CACHE_DIR is defined)
|
|
96
|
+
// Cache migration happens in migrateCursorCacheFromOldPath()
|
|
97
|
+
|
|
98
|
+
// Try to remove old config directory if empty
|
|
99
|
+
try {
|
|
100
|
+
fs.rmdirSync(OLD_CONFIG_DIR);
|
|
101
|
+
} catch {
|
|
102
|
+
// Directory not empty - ignore
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Migration failed - continue with normal operation
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function saveCursorCredentials(credentials: CursorCredentials): void {
|
|
110
|
+
ensureConfigDir();
|
|
111
|
+
fs.writeFileSync(CURSOR_CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
|
|
112
|
+
encoding: "utf-8",
|
|
113
|
+
mode: 0o600,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function loadCursorCredentials(): CursorCredentials | null {
|
|
118
|
+
migrateCursorFromOldPath();
|
|
119
|
+
try {
|
|
120
|
+
if (!fs.existsSync(CURSOR_CREDENTIALS_FILE)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const data = fs.readFileSync(CURSOR_CREDENTIALS_FILE, "utf-8");
|
|
124
|
+
const parsed = JSON.parse(data);
|
|
125
|
+
|
|
126
|
+
if (!parsed.sessionToken) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parsed as CursorCredentials;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function clearCursorCredentials(): boolean {
|
|
137
|
+
try {
|
|
138
|
+
if (fs.existsSync(CURSOR_CREDENTIALS_FILE)) {
|
|
139
|
+
fs.unlinkSync(CURSOR_CREDENTIALS_FILE);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isCursorLoggedIn(): boolean {
|
|
149
|
+
return loadCursorCredentials() !== null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// API Client
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
const CURSOR_API_BASE = "https://cursor.com";
|
|
157
|
+
const USAGE_CSV_ENDPOINT = `${CURSOR_API_BASE}/api/dashboard/export-usage-events-csv?strategy=tokens`;
|
|
158
|
+
const USAGE_SUMMARY_ENDPOINT = `${CURSOR_API_BASE}/api/usage-summary`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build HTTP headers for Cursor API requests
|
|
162
|
+
*/
|
|
163
|
+
function buildCursorHeaders(sessionToken: string): Record<string, string> {
|
|
164
|
+
return {
|
|
165
|
+
Accept: "*/*",
|
|
166
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
167
|
+
Cookie: `WorkosCursorSessionToken=${sessionToken}`,
|
|
168
|
+
Referer: "https://www.cursor.com/settings",
|
|
169
|
+
"User-Agent":
|
|
170
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Validate Cursor session token by hitting the usage-summary endpoint
|
|
176
|
+
*/
|
|
177
|
+
export async function validateCursorSession(
|
|
178
|
+
sessionToken: string
|
|
179
|
+
): Promise<{ valid: boolean; membershipType?: string; error?: string }> {
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetch(USAGE_SUMMARY_ENDPOINT, {
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers: buildCursorHeaders(sessionToken),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (response.status === 401 || response.status === 403) {
|
|
187
|
+
return { valid: false, error: "Session token expired or invalid" };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
return { valid: false, error: `API returned status ${response.status}` };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const data = await response.json();
|
|
195
|
+
|
|
196
|
+
// Check for required fields that indicate valid auth
|
|
197
|
+
if (data.billingCycleStart && data.billingCycleEnd) {
|
|
198
|
+
return { valid: true, membershipType: data.membershipType };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { valid: false, error: "Invalid response format" };
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return { valid: false, error: (error as Error).message };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fetch usage CSV from Cursor API
|
|
209
|
+
*/
|
|
210
|
+
export async function fetchCursorUsageCsv(sessionToken: string): Promise<string> {
|
|
211
|
+
const response = await fetch(USAGE_CSV_ENDPOINT, {
|
|
212
|
+
method: "GET",
|
|
213
|
+
headers: buildCursorHeaders(sessionToken),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (response.status === 401 || response.status === 403) {
|
|
217
|
+
throw new Error("Cursor session expired. Please run 'tokscale cursor login' to re-authenticate.");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
throw new Error(`Cursor API returned status ${response.status}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const text = await response.text();
|
|
225
|
+
|
|
226
|
+
// Validate it's actually CSV (handle both old and new formats)
|
|
227
|
+
// Old: "Date,Model,..."
|
|
228
|
+
// New: "Date,Kind,Model,..."
|
|
229
|
+
if (!text.startsWith("Date,")) {
|
|
230
|
+
throw new Error("Invalid response from Cursor API - expected CSV format");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return text;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// CSV Parsing
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Parse cost string (e.g., "$0.50" or "0.50") to number
|
|
242
|
+
*/
|
|
243
|
+
function parseCost(costStr: string): number {
|
|
244
|
+
if (!costStr) return 0;
|
|
245
|
+
const cleaned = costStr.replace(/[$,]/g, "").trim();
|
|
246
|
+
const value = parseFloat(cleaned);
|
|
247
|
+
return isNaN(value) ? 0 : value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Infer provider from model name
|
|
252
|
+
*/
|
|
253
|
+
function inferProvider(model: string): string {
|
|
254
|
+
const lowerModel = model.toLowerCase();
|
|
255
|
+
|
|
256
|
+
if (lowerModel.includes("claude") || lowerModel.includes("sonnet") || lowerModel.includes("opus") || lowerModel.includes("haiku")) {
|
|
257
|
+
return "anthropic";
|
|
258
|
+
}
|
|
259
|
+
if (lowerModel.includes("gpt") || lowerModel.includes("o1") || lowerModel.includes("o3")) {
|
|
260
|
+
return "openai";
|
|
261
|
+
}
|
|
262
|
+
if (lowerModel.includes("gemini")) {
|
|
263
|
+
return "google";
|
|
264
|
+
}
|
|
265
|
+
if (lowerModel.includes("deepseek")) {
|
|
266
|
+
return "deepseek";
|
|
267
|
+
}
|
|
268
|
+
if (lowerModel.includes("llama") || lowerModel.includes("mixtral")) {
|
|
269
|
+
return "meta";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return "cursor"; // Default provider
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Parse Cursor usage CSV into structured rows
|
|
277
|
+
*/
|
|
278
|
+
export function parseCursorCsv(csvText: string): CursorUsageRow[] {
|
|
279
|
+
try {
|
|
280
|
+
const records: Array<Record<string, string>> = parseCsv(csvText, {
|
|
281
|
+
columns: true,
|
|
282
|
+
skip_empty_lines: true,
|
|
283
|
+
trim: true,
|
|
284
|
+
relax_column_count: true,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return records
|
|
288
|
+
.filter((record) => record["Date"] && record["Model"])
|
|
289
|
+
.map((record) => {
|
|
290
|
+
const dateStr = record["Date"] || "";
|
|
291
|
+
const date = new Date(dateStr);
|
|
292
|
+
const isValidDate = !isNaN(date.getTime());
|
|
293
|
+
const dateOnly = isValidDate
|
|
294
|
+
? date.toISOString().slice(0, 10)
|
|
295
|
+
: dateStr.length >= 10
|
|
296
|
+
? dateStr.slice(0, 10)
|
|
297
|
+
: dateStr;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
date: dateOnly,
|
|
301
|
+
timestamp: isValidDate ? date.getTime() : 0,
|
|
302
|
+
model: (record["Model"] || "").trim(),
|
|
303
|
+
inputWithCacheWrite: parseInt(record["Input (w/ Cache Write)"] || "0", 10),
|
|
304
|
+
inputWithoutCacheWrite: parseInt(record["Input (w/o Cache Write)"] || "0", 10),
|
|
305
|
+
cacheRead: parseInt(record["Cache Read"] || "0", 10),
|
|
306
|
+
outputTokens: parseInt(record["Output Tokens"] || "0", 10),
|
|
307
|
+
totalTokens: parseInt(record["Total Tokens"] || "0", 10),
|
|
308
|
+
apiCost: parseCost(record["Cost"] || record["API Cost"] || "0"),
|
|
309
|
+
costToYou: parseCost(record["Cost to you"] || "0"),
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
} catch (error) {
|
|
313
|
+
throw new Error(`Failed to parse Cursor CSV: ${(error as Error).message}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ============================================================================
|
|
318
|
+
// Data Aggregation (for table display)
|
|
319
|
+
// ============================================================================
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Aggregate Cursor usage by model
|
|
323
|
+
*/
|
|
324
|
+
export function aggregateCursorByModel(rows: CursorUsageRow[]): CursorUsageData[] {
|
|
325
|
+
const modelMap = new Map<string, CursorUsageData>();
|
|
326
|
+
|
|
327
|
+
for (const row of rows) {
|
|
328
|
+
const key = row.model;
|
|
329
|
+
const existing = modelMap.get(key);
|
|
330
|
+
|
|
331
|
+
// Cache write = inputWithCacheWrite - inputWithoutCacheWrite (tokens written to cache)
|
|
332
|
+
const cacheWrite = Math.max(0, row.inputWithCacheWrite - row.inputWithoutCacheWrite);
|
|
333
|
+
// Input tokens (without cache) = inputWithoutCacheWrite
|
|
334
|
+
const input = row.inputWithoutCacheWrite;
|
|
335
|
+
|
|
336
|
+
if (existing) {
|
|
337
|
+
existing.messageCount += 1;
|
|
338
|
+
existing.input += input;
|
|
339
|
+
existing.output += row.outputTokens;
|
|
340
|
+
existing.cacheRead += row.cacheRead;
|
|
341
|
+
existing.cacheWrite += cacheWrite;
|
|
342
|
+
existing.cost += row.costToYou || row.apiCost;
|
|
343
|
+
} else {
|
|
344
|
+
modelMap.set(key, {
|
|
345
|
+
source: "cursor",
|
|
346
|
+
model: row.model,
|
|
347
|
+
providerId: inferProvider(row.model),
|
|
348
|
+
messageCount: 1,
|
|
349
|
+
input,
|
|
350
|
+
output: row.outputTokens,
|
|
351
|
+
cacheRead: row.cacheRead,
|
|
352
|
+
cacheWrite,
|
|
353
|
+
reasoning: 0, // Cursor doesn't expose reasoning tokens
|
|
354
|
+
cost: row.costToYou || row.apiCost,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return Array.from(modelMap.values()).sort((a, b) => b.cost - a.cost);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ============================================================================
|
|
363
|
+
// Data Conversion (for graph/native module)
|
|
364
|
+
// ============================================================================
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Convert Cursor CSV rows to timestamped messages for graph generation
|
|
368
|
+
*/
|
|
369
|
+
export function cursorRowsToMessages(rows: CursorUsageRow[]): CursorMessageWithTimestamp[] {
|
|
370
|
+
return rows.map((row) => {
|
|
371
|
+
const cacheWrite = Math.max(0, row.inputWithCacheWrite - row.inputWithoutCacheWrite);
|
|
372
|
+
const input = row.inputWithoutCacheWrite;
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
source: "cursor" as const,
|
|
376
|
+
model: row.model,
|
|
377
|
+
providerId: inferProvider(row.model),
|
|
378
|
+
timestamp: row.timestamp,
|
|
379
|
+
input,
|
|
380
|
+
output: row.outputTokens,
|
|
381
|
+
cacheRead: row.cacheRead,
|
|
382
|
+
cacheWrite,
|
|
383
|
+
reasoning: 0,
|
|
384
|
+
cost: row.costToYou || row.apiCost,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ============================================================================
|
|
390
|
+
// High-Level API
|
|
391
|
+
// ============================================================================
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Fetch and parse Cursor usage data
|
|
395
|
+
* Requires valid credentials to be stored
|
|
396
|
+
*/
|
|
397
|
+
export async function readCursorUsage(): Promise<{
|
|
398
|
+
rows: CursorUsageRow[];
|
|
399
|
+
byModel: CursorUsageData[];
|
|
400
|
+
messages: CursorMessageWithTimestamp[];
|
|
401
|
+
}> {
|
|
402
|
+
const credentials = loadCursorCredentials();
|
|
403
|
+
if (!credentials) {
|
|
404
|
+
throw new Error("Cursor not authenticated. Run 'tokscale cursor login' first.");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const csvText = await fetchCursorUsageCsv(credentials.sessionToken);
|
|
408
|
+
const rows = parseCursorCsv(csvText);
|
|
409
|
+
const byModel = aggregateCursorByModel(rows);
|
|
410
|
+
const messages = cursorRowsToMessages(rows);
|
|
411
|
+
|
|
412
|
+
return { rows, byModel, messages };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Get Cursor credentials file path (for debugging)
|
|
417
|
+
*/
|
|
418
|
+
export function getCursorCredentialsPath(): string {
|
|
419
|
+
return CURSOR_CREDENTIALS_FILE;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ============================================================================
|
|
423
|
+
// Cache Management (for Rust integration)
|
|
424
|
+
// ============================================================================
|
|
425
|
+
|
|
426
|
+
const OLD_CURSOR_CACHE_DIR = path.join(os.homedir(), ".tokscale", "cursor-cache");
|
|
427
|
+
const CURSOR_CACHE_DIR = path.join(CONFIG_DIR, "cursor-cache");
|
|
428
|
+
const CURSOR_CACHE_FILE = path.join(CURSOR_CACHE_DIR, "usage.csv");
|
|
429
|
+
|
|
430
|
+
function ensureCacheDir(): void {
|
|
431
|
+
if (!fs.existsSync(CURSOR_CACHE_DIR)) {
|
|
432
|
+
fs.mkdirSync(CURSOR_CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Migrate cursor cache from old path to new XDG path
|
|
438
|
+
*/
|
|
439
|
+
function migrateCursorCacheFromOldPath(): void {
|
|
440
|
+
try {
|
|
441
|
+
if (!fs.existsSync(CURSOR_CACHE_DIR) && fs.existsSync(OLD_CURSOR_CACHE_DIR)) {
|
|
442
|
+
ensureCacheDir();
|
|
443
|
+
fs.cpSync(OLD_CURSOR_CACHE_DIR, CURSOR_CACHE_DIR, { recursive: true });
|
|
444
|
+
fs.rmSync(OLD_CURSOR_CACHE_DIR, { recursive: true });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Try to remove old config directory if empty
|
|
448
|
+
try {
|
|
449
|
+
fs.rmdirSync(OLD_CONFIG_DIR);
|
|
450
|
+
} catch {
|
|
451
|
+
// Directory not empty - ignore
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// Migration failed - continue with normal operation
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Sync Cursor usage data from API to local cache
|
|
460
|
+
* This downloads the CSV and saves it for the Rust module to parse
|
|
461
|
+
*/
|
|
462
|
+
export async function syncCursorCache(): Promise<{ synced: boolean; rows: number; error?: string }> {
|
|
463
|
+
migrateCursorCacheFromOldPath();
|
|
464
|
+
const credentials = loadCursorCredentials();
|
|
465
|
+
if (!credentials) {
|
|
466
|
+
return { synced: false, rows: 0, error: "Not authenticated" };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const csvText = await fetchCursorUsageCsv(credentials.sessionToken);
|
|
471
|
+
ensureCacheDir();
|
|
472
|
+
fs.writeFileSync(CURSOR_CACHE_FILE, csvText, { encoding: "utf-8", mode: 0o600 });
|
|
473
|
+
|
|
474
|
+
// Count rows for feedback
|
|
475
|
+
const rows = parseCursorCsv(csvText);
|
|
476
|
+
return { synced: true, rows: rows.length };
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return { synced: false, rows: 0, error: (error as Error).message };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get the cache file path
|
|
484
|
+
*/
|
|
485
|
+
export function getCursorCachePath(): string {
|
|
486
|
+
return CURSOR_CACHE_FILE;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Check if cache exists and when it was last updated
|
|
491
|
+
*/
|
|
492
|
+
export function getCursorCacheStatus(): { exists: boolean; lastModified?: Date; path: string } {
|
|
493
|
+
const exists = fs.existsSync(CURSOR_CACHE_FILE);
|
|
494
|
+
let lastModified: Date | undefined;
|
|
495
|
+
|
|
496
|
+
if (exists) {
|
|
497
|
+
try {
|
|
498
|
+
const stats = fs.statSync(CURSOR_CACHE_FILE);
|
|
499
|
+
lastModified = stats.mtime;
|
|
500
|
+
} catch {
|
|
501
|
+
// Ignore stat errors
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { exists, lastModified, path: CURSOR_CACHE_FILE };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface CursorUnifiedMessage {
|
|
509
|
+
source: "cursor";
|
|
510
|
+
modelId: string;
|
|
511
|
+
providerId: string;
|
|
512
|
+
sessionId: string;
|
|
513
|
+
timestamp: number;
|
|
514
|
+
date: string;
|
|
515
|
+
tokens: {
|
|
516
|
+
input: number;
|
|
517
|
+
output: number;
|
|
518
|
+
cacheRead: number;
|
|
519
|
+
cacheWrite: number;
|
|
520
|
+
reasoning: number;
|
|
521
|
+
};
|
|
522
|
+
cost: number;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function readCursorMessagesFromCache(): CursorUnifiedMessage[] {
|
|
526
|
+
if (!fs.existsSync(CURSOR_CACHE_FILE)) {
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const csvText = fs.readFileSync(CURSOR_CACHE_FILE, "utf-8");
|
|
532
|
+
const rows = parseCursorCsv(csvText);
|
|
533
|
+
|
|
534
|
+
return rows.map((row) => {
|
|
535
|
+
const cacheWrite = Math.max(0, row.inputWithCacheWrite - row.inputWithoutCacheWrite);
|
|
536
|
+
const input = row.inputWithoutCacheWrite;
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
source: "cursor" as const,
|
|
540
|
+
modelId: row.model,
|
|
541
|
+
providerId: inferProvider(row.model),
|
|
542
|
+
sessionId: `cursor-${row.date}-${row.model}`,
|
|
543
|
+
timestamp: row.timestamp,
|
|
544
|
+
date: row.date,
|
|
545
|
+
tokens: {
|
|
546
|
+
input,
|
|
547
|
+
output: row.outputTokens,
|
|
548
|
+
cacheRead: row.cacheRead,
|
|
549
|
+
cacheWrite,
|
|
550
|
+
reasoning: 0,
|
|
551
|
+
},
|
|
552
|
+
cost: row.costToYou || row.apiCost,
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
} catch {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
}
|